diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 90dd210d5..7155f1339 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -78,14 +78,15 @@ import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWar import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue' import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' -import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue' import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue' +import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' import NavButton from '@/components/ui/NavButton.vue' import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue' 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' @@ -103,8 +104,13 @@ import { check_reachable } from '@/helpers/auth.js' import { get_user, get_version } from '@/helpers/cache.js' import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js' import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' -import { list, run } from '@/helpers/instance' -import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' +import { + can_current_user_use_shared_instances, + get as getInstance, + list, + run, +} from '@/helpers/instance' +import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts' import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' @@ -155,6 +161,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 @@ -200,6 +207,7 @@ const tauriApiClient = new TauriModrinthClient({ userAgent: async () => `modrinth/theseus/${await appVersion} (support@modrinth.com)`, labrinthBaseUrl: config.labrinthBaseUrl, archonBaseUrl: config.archonBaseUrl, + sharedInstancesBaseUrl: config.sharedInstancesBaseUrl, features: [ new NodeAuthFeature({ getAuth: () => nodeAuthState.getAuth?.() ?? null, @@ -223,6 +231,16 @@ const { data: authenticatedModrinthUser } = useQuery({ enabled: () => !!credentials.value?.session, retry: false, }) +useQuery({ + queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]), + queryFn: can_current_user_use_shared_instances, + enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id, + retry: false, + staleTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +}) const hasPlus = computed( () => !!credentials.value?.user && @@ -735,9 +753,10 @@ const contentInstallModpackAlreadyInstalledModal = ref() const addServerToInstanceModal = ref() const incompatibilityWarningModal = ref() const installToPlayModal = ref() +const sharedInstanceInviteHandler = ref() const updateToPlayModal = ref() -const modrinthLoginFlowWaitModal = ref() +const modrinthLoginModal = ref() watch(incompatibilityWarningModal, (modal) => { if (modal) { @@ -745,8 +764,12 @@ watch(incompatibilityWarningModal, (modal) => { } }) -setupAuthProvider(credentials, async (_redirectPath) => { - await signIn() +setupAuthProvider(credentials, async (_redirectPath, flow, options) => { + if (options?.showModal === false) { + await signIn(flow) + } else { + await requestSignIn(flow) + } }) async function validateSession(sessionToken) { @@ -763,23 +786,31 @@ 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() { - modrinthLoginFlowWaitModal.value.show() - +async function signIn(flow = 'sign-in') { try { - await login() + await login(flow) await fetchCredentials() } catch (error) { if ( @@ -791,12 +822,26 @@ async function signIn() { } else { handleError(error) } - } finally { - modrinthLoginFlowWaitModal.value.hide() } } +async function requestSignIn(flow = 'sign-in') { + await modrinthLoginModal.value?.showSigningIn(flow) +} + +async function requestModrinthAuth(flow = 'sign-in') { + await signIn(flow) + return !!credentials.value?.session +} + async function logOut() { + await performLogOut() +} + +async function performLogOut() { + credentialsRefreshId++ + credentials.value = undefined + await logout().catch(handleError) await fetchCredentials() } @@ -917,30 +962,34 @@ function openServerInviteInviterProfile(inviterName) { } async function handleLiveNotification(notification) { - if (notification?.body?.type !== 'server_invite' || notification.read) return - if (displayedServerInviteNotifications.has(notification.id)) return + if (!notification?.body || notification.read) return + if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) return - displayedServerInviteNotifications.add(notification.id) + if (notification.body.type === 'server_invite') { + if (displayedServerInviteNotifications.has(notification.id)) return - const serverName = - typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server' - const inviterId = notification.body.invited_by - const invitedBy = - typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null + displayedServerInviteNotifications.add(notification.id) - addPopupNotification({ - title: serverName, - autoCloseMs: null, - toast: { - type: 'server-invite', - actorName: invitedBy?.username ?? null, - actorAvatarUrl: invitedBy?.avatar_url ?? null, - entityName: serverName, - onAccept: () => acceptServerInviteNotification(notification), - onDecline: () => declineServerInviteNotification(notification), - onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null), - }, - }) + const serverName = + typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server' + const inviterId = notification.body.invited_by + const invitedBy = + typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null + + addPopupNotification({ + title: serverName, + autoCloseMs: null, + toast: { + type: 'server-invite', + actorName: invitedBy?.username ?? null, + actorAvatarUrl: invitedBy?.avatar_url ?? null, + entityName: serverName, + onAccept: () => acceptServerInviteNotification(notification), + onDecline: () => declineServerInviteNotification(notification), + onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null), + }, + }) + } } async function handleCommand(e) { @@ -967,6 +1016,9 @@ async function handleCommand(e) { }) } } else if (e.event === 'LaunchInstance') { + const instance = await getInstance(e.id).catch(handleError) + if (!instance || instance.quarantined) return + if (e.server) { await start_join_server(e.id, e.server).catch(handleError) } else if (e.singleplayer_world) { @@ -974,6 +1026,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) @@ -1498,7 +1552,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) - + - + @@ -1749,7 +1807,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
- +
+ diff --git a/apps/app-frontend/src/components/GridDisplay.vue b/apps/app-frontend/src/components/GridDisplay.vue index 6b9177ed6..5004b927b 100644 --- a/apps/app-frontend/src/components/GridDisplay.vue +++ b/apps/app-frontend/src/components/GridDisplay.vue @@ -65,8 +65,7 @@ async function duplicateInstance(p) { const handleRightClick = (event, instanceId) => { const item = instanceComponents.value.find((x) => x.instance.id === instanceId) const baseOptions = [ - { name: 'add_content' }, - { type: 'divider' }, + ...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]), { name: 'edit' }, { name: 'duplicate' }, { name: 'open' }, @@ -90,10 +89,14 @@ const handleRightClick = (event, instanceId) => { ...baseOptions, ] : [ - { - name: 'play', - color: 'primary', - }, + ...(item.instance.quarantined + ? [] + : [ + { + name: 'play', + color: 'primary', + }, + ]), ...baseOptions, ], ) diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue index 02b1a2a40..982b2ad0b 100644 --- a/apps/app-frontend/src/components/RowDisplay.vue +++ b/apps/app-frontend/src/components/RowDisplay.vue @@ -73,8 +73,7 @@ async function duplicateInstance(p) { const handleInstanceRightClick = async (event, passedInstance) => { const baseOptions = [ - { name: 'add_content' }, - { type: 'divider' }, + ...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]), { name: 'edit' }, { name: 'duplicate' }, { name: 'open_folder' }, @@ -98,10 +97,14 @@ const handleInstanceRightClick = async (event, passedInstance) => { ...baseOptions, ] : [ - { - name: 'play', - color: 'primary', - }, + ...(passedInstance.quarantined + ? [] + : [ + { + name: 'play', + color: 'primary', + }, + ]), ...baseOptions, ] diff --git a/apps/app-frontend/src/components/ui/Instance.vue b/apps/app-frontend/src/components/ui/Instance.vue index 9fff27fa3..6845a00f2 100644 --- a/apps/app-frontend/src/components/ui/Instance.vue +++ b/apps/app-frontend/src/components/ui/Instance.vue @@ -66,6 +66,7 @@ const checkProcess = async () => { const play = async (e, context) => { e?.stopPropagation() + if (props.instance.quarantined) return loading.value = true await run(props.instance.id) .catch((err) => handleSevereError(err, { instanceId: props.instance.id })) @@ -94,6 +95,7 @@ const stop = async (e, context) => { const repair = async (e) => { e?.stopPropagation() + if (props.instance.quarantined) return if ( props.instance.install_stage !== 'pack_installed' && @@ -116,6 +118,7 @@ const openFolder = async () => { } const addContent = async () => { + if (props.instance.quarantined) return await router.push({ path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`, query: { i: props.instance.id }, @@ -142,7 +145,9 @@ const unlisten = await process_listener((e) => { } }) -onMounted(() => checkProcess()) +onMounted(() => { + checkProcess() +}) onUnmounted(() => unlisten()) @@ -173,7 +178,11 @@ onUnmounted(() => unlisten()) - + - + - - - + + + + + + (), { iconSrc: null, @@ -212,6 +287,7 @@ const props = withDefaults( ping: undefined, minecraftServer: undefined, linkedProjectV3: undefined, + sharedInstanceManager: null, }, ) @@ -224,6 +300,7 @@ const emit = defineEmits<{ openFolder: [] export: [] createShortcut: [] + report: [event?: MouseEvent] }>() const installingStages = [ @@ -241,6 +318,21 @@ const loaderDisplayName = computed(() => formatLoaderLabel(props.instance.loader const loaderLabel = computed(() => [loaderDisplayName.value, props.instance.loader_version].filter(Boolean).join(' '), ) +const sharedInstanceTooltip = computed(() => + formatMessage( + props.instance.shared_instance?.role === 'owner' + ? messages.sharedInstanceOwnerTooltip + : messages.sharedInstanceTooltip, + ), +) +const sharedInstanceManagerLabel = computed(() => + props.sharedInstanceManager?.type === 'server' ? 'Linked to' : 'Managed by', +) +const sharedInstanceManagerAction = computed(() => { + const manager = props.sharedInstanceManager + if (manager?.type !== 'user') return undefined + return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(manager.name)}`) +}) const playtimeLabel = computed(() => { if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed) @@ -271,24 +363,46 @@ const serverPlayActions = computed(() => [ action: () => emit('play'), }, ]) -const moreActions = computed(() => [ - { - id: 'open-folder', - label: formatMessage(messages.openFolder), - icon: FolderOpenIcon, - action: () => emit('openFolder'), - }, - { - id: 'export-mrpack', - label: formatMessage(messages.exportModpack), - icon: PackageIcon, - action: () => emit('export'), - }, - { - id: 'create-shortcut', - label: formatMessage(messages.createShortcut), - icon: ExternalIcon, - action: () => emit('createShortcut'), - }, -]) +const moreActions = computed(() => { + const actions: TeleportOverflowMenuItem[] = [ + { + id: 'open-folder', + label: formatMessage(messages.openFolder), + icon: FolderOpenIcon, + action: () => emit('openFolder'), + }, + ] + + if (!props.instance.quarantined) { + actions.push( + { + id: 'export-mrpack', + label: formatMessage(messages.exportModpack), + icon: PackageIcon, + action: () => emit('export'), + }, + { + id: 'create-shortcut', + label: formatMessage(messages.createShortcut), + icon: ExternalIcon, + action: () => emit('createShortcut'), + }, + ) + } + + if (props.instance.shared_instance?.role === 'member') { + actions.push( + { divider: true }, + { + id: 'report-shared-instance', + label: formatMessage(commonMessages.reportButton), + icon: ReportIcon, + color: 'red', + action: (event) => emit('report', event), + }, + ) + } + + return actions +}) 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 000000000..4d73ef688 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue @@ -0,0 +1,116 @@ + + + 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 000000000..5191ae9b2 --- /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 000000000..e437bfe1c --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue @@ -0,0 +1,66 @@ + + + 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 000000000..88dc3b715 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue @@ -0,0 +1,51 @@ + + + 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 000000000..aab1f2fa8 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue @@ -0,0 +1,78 @@ + + + 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 000000000..ea7eb81be --- /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 fa1a4d031..fe210e165 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,24 +15,25 @@ 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, update_managed_modrinth_version, update_repair_modrinth, } from '@/helpers/instance' import { get_loader_versions } from '@/helpers/metadata' import { get_game_versions, get_loaders } from '@/helpers/tags' +import { provideInstanceBackup } from '@/providers/instance-backup' import { injectInstanceSettings } from '@/providers/instance-settings' import { useTheming } from '@/store/state' @@ -47,6 +47,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 +112,14 @@ 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 canUnlinkSharedInstance = managedContentPolicy.canUnlink const modpackInfoQuery = useQuery({ queryKey: computed(() => ['linkedModpackInfo', instance.value.id]), @@ -124,12 +130,43 @@ const modpackInfo = modpackInfoQuery.data const repairing = ref(false) const reinstalling = ref(false) +const unlinkingSharedInstance = ref(false) +const installationSettingsBusy = computed( + () => + instance.value.quarantined || + instance.value.install_stage !== 'installed' || + repairing.value || + reinstalling.value || + unlinkingSharedInstance.value || + !!offline, +) +const installationSettingsBusyMessage = computed(() => + instance.value.quarantined ? formatMessage(messages.locked) : null, +) + +async function unlinkSharedInstance() { + unlinkingSharedInstance.value = true + try { + await unlink_shared_instance(instance.value.id) + await queryClient.invalidateQueries({ queryKey: ['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: { id: 'instance.settings.tabs.installation.loader-version', defaultMessage: '{loader} version', }, + locked: { + id: 'instance.settings.tabs.installation.locked', + defaultMessage: 'Installation settings are unavailable while this instance is locked.', + }, }) function getManifest(loader: string) { @@ -162,27 +199,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,14 +225,14 @@ provideInstallationSettings({ } return rows }), - isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value), - isBusy: computed( + isLinked: computed( () => - instance.value.install_stage !== 'installed' || - repairing.value || - reinstalling.value || - !!offline, + isModrinthLinkedModpack.value || + isImportedModpack.value || + isSharedInstanceManagedModpack.value, ), + isBusy: installationSettingsBusy, + busyMessage: installationSettingsBusyMessage, skipNonEssentialWarnings, modpack: computed(() => { if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') { @@ -452,14 +469,28 @@ 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/instance_settings/SharingSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue new file mode 100644 index 000000000..aef391eea --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue @@ -0,0 +1,42 @@ + + + diff --git a/apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue b/apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue deleted file mode 100644 index 03f3036f9..000000000 --- a/apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue +++ /dev/null @@ -1,43 +0,0 @@ - - diff --git a/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue index 31ccce12f..912725f5e 100644 --- a/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue @@ -4,8 +4,8 @@ :header="formatMessage(messages.installToPlay)" :closable="true" :on-hide="show_ads_window" - max-width="640px" - width="640px" + max-width="544px" + width="544px" >

@@ -423,8 +423,8 @@ const messages = defineMessages({ defaultMessage: 'Install anyway', }, dontInstall: { - id: 'app.modal.install-to-play.dont-install', - defaultMessage: 'Dont install', + id: 'app.modal.install-to-play.external-files-dont-install', + defaultMessage: "Don't install", }, }) diff --git a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue index bedef146b..800cac656 100644 --- a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue @@ -6,6 +6,7 @@ import { CoffeeIcon, InfoIcon, MonitorIcon, + UsersIcon, WrenchIcon, } from '@modrinth/assets' import { @@ -25,6 +26,7 @@ import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.v import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue' import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue' import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue' +import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue' import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue' import { get_project_v3 } from '@/helpers/cache' import { get_linked_modpack_info } from '@/helpers/instance' @@ -97,6 +99,15 @@ const tabs = computed(() => [ icon: WrenchIcon, content: InstallationSettings, }, + { + name: defineMessage({ + id: 'instance.settings.tabs.sharing', + defaultMessage: 'Sharing', + }), + icon: UsersIcon, + content: SharingSettings, + shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined, + }, { name: defineMessage({ id: 'instance.settings.tabs.window', 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 000000000..db5dbc880 --- /dev/null +++ b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue @@ -0,0 +1,265 @@ + + + diff --git a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue index 9c756e6d6..ba5cff8b2 100644 --- a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue +++ b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue @@ -8,6 +8,7 @@ :diffs="normalizedDiffs" :version-date="versionDate" :show-external-warnings="showExternalWarnings" + :external-warning-description="formatMessage(messages.externalWarningDescription)" :confirm-label="formatMessage(commonMessages.updateButton)" :confirm-icon="DownloadIcon" :removed-label="formatMessage(messages.removed)" @@ -76,10 +77,15 @@ const { formatMessage } = useVIntl() const { startInstallingServer, stopInstallingServer } = injectServerInstall() type UpdateCompleteCallback = () => void | Promise -defineProps<{ +const { showExternalWarnings } = defineProps<{ showExternalWarnings?: boolean }>() +const emit = defineEmits<{ + cancel: [] + complete: [] +}>() + const diffModal = ref>() const instance = ref(null) const onUpdateComplete = ref(() => {}) @@ -87,16 +93,16 @@ 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, external: Boolean(diff.fileName && !diff.project), projectName: diff.project?.title, fileName: diff.fileName, currentVersionName: diff.currentVersion?.version_number, newVersionName: diff.newVersion?.version_number, - })), -) + })) +}) const versionDate = computed(() => modpackVersion.value?.date_published @@ -242,7 +248,6 @@ async function checkUpdateAvailable(inst: GameInstance): Promise { diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue new file mode 100644 index 000000000..3e5922027 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue @@ -0,0 +1,100 @@ + + + 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 000000000..d72f17090 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue @@ -0,0 +1,195 @@ + + + diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue new file mode 100644 index 000000000..05b9ba6e8 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue @@ -0,0 +1,189 @@ + + + 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 000000000..4767a6ff0 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue @@ -0,0 +1,131 @@ + + + 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 000000000..e4ebfd949 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue @@ -0,0 +1,533 @@ + + + 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 000000000..8db6f7abb --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue @@ -0,0 +1,60 @@ + + + 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 000000000..671b4b189 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts @@ -0,0 +1,135 @@ +import type { Labrinth } from '@modrinth/api-client' +import type { ContentItem } from '@modrinth/ui' + +import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js' +import type { SharedInstanceInstallPreview } from '@/helpers/install' + +type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string } + +export function useSharedInstancePreviewContent() { + async function load(preview: SharedInstanceInstallPreview): Promise { + 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 000000000..2229707f4 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue @@ -0,0 +1,46 @@ + + + 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 000000000..60803ee4a --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts @@ -0,0 +1,29 @@ +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.shared_instance_icon), + } +} 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 000000000..0227b4542 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts @@ -0,0 +1,29 @@ +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 + shared_instance_icon?: 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 +} 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 000000000..58f4c1480 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts @@ -0,0 +1,252 @@ +import { ModrinthApiError } from '@modrinth/api-client' +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 { useRouter } from 'vue-router' + +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 { list } from '@/helpers/instance' +import { useTheming } from '@/store/state' + +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 +} + +type AlreadyInstalledModal = { + show(instanceName: string): void +} + +export function useSharedInstanceInviteHandler( + installModal: Ref, + alreadyInstalledModal: Ref, + accountRequiredModal: Ref, +) { + const auth = injectAuth() + const client = injectModrinthClient() + const { handleError } = injectNotificationManager() + const { addPopupNotification } = injectPopupNotificationManager() + const queryClient = useQueryClient() + const router = useRouter() + const themeStore = useTheming() + const displayedNotifications = new Set() + let pendingAlreadyInstalled: + | { + instanceId: string + preview: Awaited> + install: () => Promise + onGoToInstance?: () => void | Promise + } + | undefined + + async function markNotificationRead(notification: AppNotification) { + try { + await client.labrinth.notifications_v2.markAsRead(String(notification.id)) + } catch (error) { + if (error instanceof ModrinthApiError && error.statusCode === 404) return + throw error + } + } + + async function resolveInvite(invite: SharedInstanceInvite) { + const [invitedBy, sharedInstance] = await Promise.all([ + !invite.invitedByUsername && invite.invitedById + ? get_user(invite.invitedById, 'bypass').catch(() => null) + : null, + client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null), + ]) + + return { + ...invite, + invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null, + invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null, + instanceIconUrl: sharedInstance ? sharedInstance.icon : invite.instanceIconUrl, + } + } + + 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 showInstallOrAlreadyInstalled( + sharedInstanceId: string, + preview: Awaited>, + install: () => Promise, + onGoToInstance?: () => void | Promise, + ) { + const existingInstance = (await list()).find( + (instance) => instance.shared_instance?.id === sharedInstanceId, + ) + + if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) { + showInstall(preview, install) + return + } + + if (!alreadyInstalledModal.value) { + throw new Error('Shared instance already installed modal is not available.') + } + + pendingAlreadyInstalled = { + instanceId: existingInstance.id, + preview, + install, + onGoToInstance, + } + alreadyInstalledModal.value.show(existingInstance.name) + } + + function handleAlreadyInstalledCancel() { + pendingAlreadyInstalled = undefined + } + + async function handleAlreadyInstalledGoToInstance() { + const pending = pendingAlreadyInstalled + pendingAlreadyInstalled = undefined + if (!pending) return + + if (pending.onGoToInstance) { + try { + await pending.onGoToInstance() + } catch (error) { + handleError(toError(error)) + } + } + await router.push(`/instance/${encodeURIComponent(pending.instanceId)}/`) + } + + function handleAlreadyInstalledInstallAnyway() { + const pending = pendingAlreadyInstalled + pendingAlreadyInstalled = undefined + if (!pending) return + showInstall(pending.preview, pending.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 + + await showInstallOrAlreadyInstalled( + invite.sharedInstanceId, + preview, + async () => { + await install_shared_instance( + invite.sharedInstanceId, + invite.sharedInstanceName, + invite.invitedById, + null, + null, + invite.instanceIconUrl, + ) + await markNotificationRead(notification) + await queryClient.invalidateQueries({ queryKey: ['instances'] }) + }, + () => markNotificationRead(notification), + ) + } 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) + await showInstallOrAlreadyInstalled(invite.sharedInstanceId, 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, + handleAlreadyInstalledCancel, + handleAlreadyInstalledGoToInstance, + handleAlreadyInstalledInstallAnyway, + } +} diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue index f76801f9c..d2c2388ae 100644 --- a/apps/app-frontend/src/components/ui/world/InstanceItem.vue +++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue @@ -80,6 +80,7 @@ const playing = ref(false) const play = async (event: MouseEvent) => { event?.stopPropagation() + if (props.instance.quarantined) return loading.value = true await run(props.instance.id) .catch((err) => handleSevereError(err, { instanceId: props.instance.id })) @@ -192,8 +193,14 @@ onUnmounted(() => {

+
@@ -107,10 +131,11 @@ import { StopCircleIcon, TerminalSquareIcon, UpdatedIcon, + UserPlusIcon, XIcon, } from '@modrinth/assets' -import { injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui' -import { useQueryClient } from '@tanstack/vue-query' +import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui' +import { useQuery, useQueryClient } from '@tanstack/vue-query' import { convertFileSrc } from '@tauri-apps/api/core' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' @@ -119,9 +144,13 @@ import { useRoute, useRouter } from 'vue-router' import ContextMenu from '@/components/ui/ContextMenu.vue' import ExportModal from '@/components/ui/ExportModal.vue' +import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue' import InstancePageHeader from '@/components/ui/instance-page-header/index.vue' +import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' +import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue' +import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue' import { fetchCachedServerStatus, getFreshCachedServerStatus, @@ -130,10 +159,25 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole' import { trackEvent } from '@/helpers/analytics' import { get_project_v3 } from '@/helpers/cache.js' import { instance_listener, process_listener } from '@/helpers/events' -import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install' -import { get, get_full_path, kill, run } from '@/helpers/instance' +import { + getSharedInstanceUnavailableReason, + install_existing_instance, + install_get_shared_instance_preview, + install_pack_to_existing_instance, + isSharedInstanceUnavailableError, + type SharedInstanceUnavailableReason, +} from '@/helpers/install' +import { + can_current_user_use_shared_instances, + get, + get_full_path, + kill, + remove, + run, +} from '@/helpers/instance' import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content' import { get_by_instance_id } from '@/helpers/process' +import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors' import type { GameInstance } from '@/helpers/types' import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js' import { refreshWorlds, type ServerStatus } from '@/helpers/worlds' @@ -141,10 +185,13 @@ import { injectServerInstall } from '@/providers/server-install' import { handleSevereError } from '@/store/error.js' import { useBreadcrumbs, useTheming } from '@/store/state' +import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state' + dayjs.extend(relativeTime) const { addNotification, handleError } = injectNotificationManager() const { playServerProject } = injectServerInstall() +const auth = injectAuth() const queryClient = useQueryClient() const route = useRoute() @@ -167,17 +214,23 @@ const instance = ref() const preloadedContent = ref(null) const playing = ref(false) const loading = ref(false) +const checkingSharedInstanceLaunch = ref(false) const subpagePending = ref(false) const stopping = ref(false) const exportModal = ref>() const updateToPlayModal = ref>() +const sharedInstanceUpdateModal = ref>() +const sharedInstanceReportModal = ref>() +const deleteConfirmModal = ref>() +const selectedInstanceToDelete = ref(null) + +const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors() useLoadingBarToken(subpagePending) const isServerInstance = ref(false) const linkedProjectV3 = ref() const selected = ref([]) - const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server) const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data) const liveServerStatusOnline = ref(false) @@ -189,11 +242,27 @@ const playersOnline = ref(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, + refreshUpdatePreview: refreshSharedInstanceUpdatePreview, + setUnavailable: setSharedInstanceUnavailable, + signedOut: sharedInstanceSignedOut, + unavailableManager: sharedInstanceUnavailableManager, + unavailableReason: sharedInstanceUnavailableReason, + wrongAccount: sharedInstanceWrongAccount, +} = sharedInstanceState watch( () => router.currentRoute.value, (nextRoute) => { - if (nextRoute.path.startsWith('/instance')) { + if ( + nextRoute.path.startsWith('/instance') && + (!instance.value || nextRoute.params.id === instance.value.id) + ) { displayedInstanceRoute.value = nextRoute } }, @@ -219,17 +288,15 @@ function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) { } async function fetchInstance() { - isServerInstance.value = false - linkedProjectV3.value = undefined - preloadedContent.value = null - resetServerStatus() + const requestedInstanceId = route.params.id as string + const requestedRouteName = route.name - const nextInstance = await get(route.params.id as string).catch(handleError) + const nextInstance = await get(requestedInstanceId).catch(handleError) let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined let nextIsServerInstance = false const contentPreloadPromise = - nextInstance && isContentSubpageRoute() + nextInstance && isContentSubpageRoute(requestedRouteName) ? loadInstanceContentData(nextInstance.id, undefined, handleError) : Promise.resolve(null) @@ -245,13 +312,25 @@ async function fetchInstance() { } } - const nextPreloadedContent = await contentPreloadPromise + let nextPreloadedContent = await contentPreloadPromise + let nextRoute = router.currentRoute.value + if (nextRoute.params.id !== requestedInstanceId) return + + if (nextInstance && isContentSubpageRoute(nextRoute.name) && !nextPreloadedContent) { + nextPreloadedContent = await loadInstanceContentData(nextInstance.id, undefined, handleError) + nextRoute = router.currentRoute.value + if (nextRoute.params.id !== requestedInstanceId) return + } instance.value = nextInstance ?? undefined + displayedInstanceRoute.value = nextRoute + sharedInstanceState.reset() + sharedInstanceState.refreshAvailability() linkedProjectV3.value = nextLinkedProjectV3 isServerInstance.value = nextIsServerInstance preloadedContent.value = nextPreloadedContent activeInstanceId.value = nextInstance?.id + resetServerStatus() fetchDeferredData(nextInstance?.id) @@ -335,29 +414,78 @@ const isFixedRender = computed(() => renderMode.value === 'fixed') const contentSubpageProps = computed(() => isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {}, ) +const { data: canCurrentUserUseSharedInstances } = useQuery({ + queryKey: computed(() => ['shared-instance-eligibility', auth.user.value?.id]), + queryFn: can_current_user_use_shared_instances, + enabled: () => !!auth.session_token.value && !!auth.user.value?.id, + retry: false, + staleTime: Infinity, + refetchOnMount: 'always', + refetchOnWindowFocus: false, + refetchOnReconnect: false, +}) +const currentUserCanUseSharedInstances = computed( + () => !auth.session_token.value || canCurrentUserUseSharedInstances.value !== false, +) +const showShareTab = computed(() => { + const linkType = instance.value?.link?.type -const tabs = computed(() => [ - { - label: 'Content', - href: `${basePath.value}`, - icon: BoxesIcon, + return ( + currentUserCanUseSharedInstances.value && + !instance.value?.quarantined && + instance.value?.shared_instance?.role !== 'member' && + linkType !== 'server_project' && + linkType !== 'server_project_modpack' + ) +}) + +const tabs = computed(() => { + const instanceTabs = [ + { + label: 'Content', + href: `${basePath.value}`, + icon: BoxesIcon, + }, + { + label: 'Files', + href: `${basePath.value}/files`, + icon: FolderOpenIcon, + }, + { + label: 'Worlds', + href: `${basePath.value}/worlds`, + icon: GlobeIcon, + }, + { + label: 'Logs', + href: `${basePath.value}/logs`, + icon: TerminalSquareIcon, + }, + ] + + if (showShareTab.value) { + instanceTabs.push({ + label: 'Share', + href: `${basePath.value}/share`, + icon: UserPlusIcon, + }) + } + + return instanceTabs +}) + +watch( + () => ({ + quarantined: instance.value?.quarantined ?? false, + routeName: router.currentRoute.value.name, + }), + ({ quarantined, routeName }) => { + if (quarantined && routeName === 'InstanceShare') { + void router.replace(basePath.value) + } }, - { - label: 'Files', - href: `${basePath.value}/files`, - icon: FolderOpenIcon, - }, - { - label: 'Worlds', - href: `${basePath.value}/worlds`, - icon: GlobeIcon, - }, - { - label: 'Logs', - href: `${basePath.value}/logs`, - icon: TerminalSquareIcon, - }, -]) + { immediate: true }, +) if (instance.value) { breadcrumbs.setName( @@ -375,13 +503,8 @@ if (instance.value) { const options = ref | null>(null) -const startInstance = async (context: string) => { - if (!instance.value) return - if (updateToPlayModal.value?.hasUpdate) { - updateToPlayModal.value.show(instance.value) - return - } - +const launchInstance = async (context: string) => { + if (!instance.value || instance.value.quarantined) return loading.value = true try { await run(route.params.id as string) @@ -391,6 +514,7 @@ const startInstance = async (context: string) => { } loading.value = false + if (!instance.value) return trackEvent('InstanceStart', { loader: instance.value.loader, game_version: instance.value.game_version, @@ -398,6 +522,65 @@ const startInstance = async (context: string) => { }) } +async function handleSharedInstanceUnavailable( + reason: SharedInstanceUnavailableReason | null = null, +) { + notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value) + await fetchInstance() + setSharedInstanceUnavailable(reason) +} + +const startInstance = async (context: string) => { + if (!instance.value || instance.value.quarantined) return + if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return + + const instanceId = instance.value.id + const isSharedInstanceMember = instance.value.shared_instance?.role === 'member' + const canCheckSharedInstanceUpdate = + !!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value + + if (canCheckSharedInstanceUpdate) { + let preview: Awaited> + checkingSharedInstanceLaunch.value = true + try { + preview = await refreshSharedInstanceUpdatePreview() + } catch (error) { + if (isSharedInstanceUnavailableError(error)) { + await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error)) + } else { + notifySharedInstanceError(error) + } + return + } finally { + checkingSharedInstanceLaunch.value = false + } + + if (instance.value?.id !== instanceId) return + + if (preview?.updateAvailable && sharedInstanceUpdateModal.value) { + sharedInstanceUpdateModal.value.show(instance.value, preview, async () => { + await fetchInstance() + await launchInstance(context) + }) + return + } + } + + if (updateToPlayModal.value?.hasUpdate) { + if (isSharedInstanceMember) { + updateToPlayModal.value.show(instance.value, null, async () => { + await fetchInstance() + await launchInstance(context) + }) + } else { + updateToPlayModal.value.show(instance.value) + } + return + } + + await launchInstance(context) +} + const stopInstance = async (context: string) => { stopping.value = true await kill(route.params.id as string).catch(handleError) @@ -413,7 +596,7 @@ const stopInstance = async (context: string) => { } const handlePlayServer = async () => { - if (!instance.value?.link?.project_id) return + if (!instance.value?.link?.project_id || instance.value.quarantined) return loading.value = true try { await playServerProject(instance.value.link.project_id) @@ -424,6 +607,7 @@ const handlePlayServer = async () => { } const repairInstance = async () => { + if (instance.value.quarantined) return if ( instance.value.install_stage !== 'pack_installed' && (instance.value.link?.type === 'modrinth_modpack' || @@ -441,7 +625,7 @@ const repairInstance = async () => { } const createShortcut = async () => { - if (!instance.value) return + if (!instance.value || instance.value.quarantined) return try { const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id) if (!shortcutPath) return @@ -459,10 +643,51 @@ const createShortcut = async () => { } } +async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false) { + const reportInstance = instance.value + const sharedInstance = reportInstance?.shared_instance + if (!reportInstance || sharedInstance?.role !== 'member') return + + try { + const preview = await install_get_shared_instance_preview( + sharedInstance.id, + reportInstance.name, + ) + if (instance.value?.id !== reportInstance.id) return + if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide() + sharedInstanceReportModal.value?.showReport(preview, event) + } catch (error) { + handleError(error as Error) + } +} + +function handleSharedInstanceReported(deleteInstance: boolean) { + if (!deleteInstance || !instance.value) return + requestInstanceDeletion() +} + +function requestInstanceDeletion() { + if (!instance.value) return + selectedInstanceToDelete.value = instance.value + deleteConfirmModal.value?.show() +} + +async function deleteSelectedInstance() { + const selectedInstance = selectedInstanceToDelete.value + selectedInstanceToDelete.value = null + if (!selectedInstance) return + + trackEvent('InstanceRemove', { + loader: selectedInstance.loader, + game_version: selectedInstance.game_version, + }) + await router.push({ path: '/' }) + await remove(selectedInstance.id).catch(handleError) +} + const handleRightClick = (event: MouseEvent) => { const baseOptions = [ - { name: 'add_content' }, - { type: 'divider' }, + ...(instance.value?.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]), { name: 'edit' }, { name: 'open_folder' }, { name: 'copy_path' }, @@ -480,10 +705,14 @@ const handleRightClick = (event: MouseEvent) => { ...baseOptions, ] : [ - { - name: 'play', - color: 'primary', - }, + ...(instance.value?.quarantined + ? [] + : [ + { + name: 'play', + color: 'primary', + }, + ]), ...baseOptions, ], ) diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index 93db7a5ee..1da28f349 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -19,14 +19,26 @@ ref="modpackContentModal" :modpack-name="displayedModpackProject?.title" :modpack-icon-url="displayedModpackProject?.icon_url ?? undefined" - :enable-toggle="!props.isServerInstance" + :enable-toggle="!props.isServerInstance && !isSharedMember && !isQuarantined" :busy="isBulkOperating" :get-overflow-options="getOverflowOptions" - :switch-version="handleSwitchVersion" + :switch-version=" + props.isServerInstance || isSharedMember || isQuarantined + ? 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, + isQuarantined, + canMutateContent, + canUpdateContent: canUpdateProject, +} = managedContentPolicy function hasPreloadedContent(contentData: InstanceContentData | null | undefined) { return contentData?.path === props.instance.id @@ -287,6 +314,7 @@ const isBulkOperating = ref(false) const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed') const isPackLocked = computed( () => + props.instance.quarantined || props.instance?.link?.type === 'modrinth_modpack' || props.instance?.link?.type === 'server_project_modpack', ) @@ -296,6 +324,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 unknownFileWarningModal = ref | null>() const unknownFileName = ref('') let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null @@ -377,8 +407,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) { @@ -486,7 +516,7 @@ async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: st } async function handleBrowseContent() { - if (!props.instance) return + if (!props.instance || props.instance.quarantined) return await router.push({ path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`, query: { i: props.instance.id }, @@ -494,7 +524,7 @@ async function handleBrowseContent() { } async function handleUploadFiles() { - if (!props.instance) return + if (!props.instance || props.instance.quarantined) return const files = await open({ multiple: true }) if (!files) return const selectedFiles: Array<{ path: string; filename: string }> = [] @@ -517,26 +547,37 @@ async function handleUploadFiles() { }), ) - const addedFiles: string[] = [] + const confirmedFiles: Array<{ path: string; filename: string }> = [] for (const [index, { path, filename }] of selectedFiles.entries()) { if (!fileRecognition[index] && !(await confirmUnknownFileInstallation(filename))) { continue } - try { - await add_project_from_path(props.instance.id, path) - addedFiles.push(filename) - } catch (e) { - handleError(e as Error) - } + confirmedFiles.push({ path, filename }) } - await initProjects() - if (addedFiles.length > 0) { - const names = addedFiles.map((f) => { - const item = projects.value.find( - (p) => p.file_name === f || p.file_name === f.replace('.zip', '.jar'), - ) - return item?.project?.title ?? f + const addedFiles = ( + await Promise.all( + confirmedFiles.map(async ({ path, filename }) => { + try { + const installedPath = await add_project_from_path(props.instance.id, path) + return { filename, installedPath } + } catch (error) { + handleError(error as Error) + return null + } + }), + ) + ).filter((result): result is { filename: string; installedPath: string } => result !== null) + const uniqueAddedFiles = [ + ...new Map(addedFiles.map((file) => [file.installedPath, file])).values(), + ] + + await initProjects('must_revalidate') + + if (uniqueAddedFiles.length > 0) { + const names = uniqueAddedFiles.map(({ filename, installedPath }) => { + const item = projects.value.find((project) => project.file_path === installedPath) + return item?.project?.title ?? filename }) addNotification({ type: 'success', @@ -784,6 +825,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 @@ -920,6 +962,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() @@ -947,10 +990,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))) } @@ -1300,29 +1365,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, @@ -1378,21 +1421,31 @@ provideContentManager({ }), isPackLocked, isBusy: isInstanceBusy, + disableAddContent: isQuarantined, + disableAddContentTooltip: formatMessage(messages.lockedContent), isBulkOperating, skipNonEssentialWarnings, contentTypeLabel: ref(formatMessage(messages.contentTypeProject)), 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, @@ -1401,14 +1454,15 @@ provideContentManager({ updateItem: handleUpdate, bulkUpdateAll: bulkUpdateAllProjects, bulkUpdateItem: updateProject, - updateModpack: props.isServerInstance ? undefined : handleModpackUpdate, + updateModpack: + props.isServerInstance || isSharedMember.value || isQuarantined.value + ? undefined + : handleModpackUpdate, viewModpackContent: handleModpackContent, unlinkModpack: unpairInstance, openSettings: props.openSettings, switchVersion: handleSwitchVersion, getOverflowOptions, - showContentHint, - dismissContentHint, shareItems: handleShareItems, getItemId: getContentItemId, mapToTableItem: (item: ContentItem) => ({ @@ -1440,8 +1494,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/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue index 6d6be96fc..1ddcb5e94 100644 --- a/apps/app-frontend/src/pages/instance/Worlds.vue +++ b/apps/app-frontend/src/pages/instance/Worlds.vue @@ -88,6 +88,7 @@ :highlighted="highlightedWorld === getWorldIdentifier(world)" :supports-server-quick-play="supportsServerQuickPlay" :supports-world-quick-play="supportsWorldQuickPlay" + :quarantined="instance.quarantined" :current-protocol="protocolVersion" :playing-instance="playing" :playing-world="worldsMatch(world, worldPlaying)" @@ -273,6 +274,7 @@ const instance = computed(() => props.instance) const playing = computed(() => props.playing) function play(world: World) { + if (props.instance.quarantined) return emit('play', world) } @@ -523,6 +525,7 @@ function handleJoinError(err: Error) { } async function joinWorld(world: World) { + if (instance.value.quarantined) return console.log(`Joining world ${getWorldIdentifier(world)}`) startingInstance.value = true worldPlaying.value = world diff --git a/apps/app-frontend/src/pages/instance/index.js b/apps/app-frontend/src/pages/instance/index.js index b96705e88..0e6a0e7ec 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 000000000..2588ba79f --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/index.vue @@ -0,0 +1,373 @@ + + + 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 000000000..223c915c0 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue @@ -0,0 +1,304 @@ + + + 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 000000000..1f7983dcd --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue @@ -0,0 +1,122 @@ + + + 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 000000000..22839fe4d --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue @@ -0,0 +1,15 @@ + + + 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 000000000..a6e478ba4 --- /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 000000000..b25e9e91d --- /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 000000000..7a7d0a77a --- /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 000000000..48bca947e --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts @@ -0,0 +1,241 @@ +import type { InvitePlayersUser } from '@modrinth/ui' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, type Ref } from 'vue' + +import { get_user_many } from '@/helpers/cache.js' +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 MembersQueryKey = readonly ['sharedInstanceUsers', string] + +type OptimisticChange = { + queryKey: MembersQueryKey + userId: string + previousRow?: ShareRow + previousIndex: number +} + +type InviteVariables = { + user: InvitePlayersUser + change: OptimisticChange +} + +type RemoveVariables = { + id: string + hasPendingRecipients: boolean + change: OptimisticChange +} + +export function useSharedInstanceMembers(options: { + instance: Ref + currentUserId: Ref + isSignedIn: Ref + actionsLocked: Ref + onError: (error: unknown) => void +}) { + const queryClient = useQueryClient() + const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const) + const invitingUserIds = new Set() + const removingUserIds = new Set() + + const query = useQuery({ + queryKey, + queryFn: ({ queryKey }) => fetchRows(queryKey), + enabled: () => + options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value, + staleTime: Infinity, + refetchOnMount: 'always', + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }) + const rows = computed(() => query.data.value ?? []) + + const inviteMutation = useMutation({ + mutationFn: ({ user, change }: InviteVariables) => + invite_shared_instance_users(change.queryKey[1], [user.id]), + onError: (error, { change }) => { + rollback(change) + options.onError(error) + }, + onSettled: (_data, _error, { user }) => { + invitingUserIds.delete(normalizeInviteKey(user.id)) + }, + }) + + const removeMutation = useMutation({ + mutationFn: ({ id, hasPendingRecipients, change }: RemoveVariables) => + remove_shared_instance_users(change.queryKey[1], [id], hasPendingRecipients), + onError: (error, { change }) => { + rollback(change) + options.onError(error) + }, + onSettled: (_data, _error, { id }) => { + removingUserIds.delete(normalizeInviteKey(id)) + }, + }) + + async function fetchRows(activeQueryKey: MembersQueryKey) { + const users = await get_shared_instance_users(activeQueryKey[1]) + const loadedRows = await usersToRows(users) + const currentRows = queryClient.getQueryData(activeQueryKey) ?? [] + return preserveRowOrder(loadedRows, currentRows) + } + + 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, + } + }) + } + + 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) { + const normalizedId = normalizeInviteKey(user.id) + if ( + options.actionsLocked.value || + invitingUserIds.has(normalizedId) || + find(user.id, user.username) + ) { + return + } + + invitingUserIds.add(normalizedId) + const change = beginOptimisticChange(user.id) + updateRows(change.queryKey, (currentRows) => [...currentRows, inviteUserToRow(user)]) + inviteMutation.mutate({ user, change }) + } + + function remove(id: string) { + const normalizedId = normalizeInviteKey(id) + if (options.actionsLocked.value || removingUserIds.has(normalizedId)) return + + removingUserIds.add(normalizedId) + const hasPendingRecipients = rows.value.some( + (row) => row.pending && normalizeInviteKey(row.id) !== normalizedId, + ) + const change = beginOptimisticChange(id) + updateRows(change.queryKey, (currentRows) => + currentRows.filter((row) => normalizeInviteKey(row.id) !== normalizedId), + ) + removeMutation.mutate({ id, hasPendingRecipients, change }) + } + + function beginOptimisticChange(userId: string): OptimisticChange { + const activeQueryKey = queryKey.value + void queryClient.cancelQueries({ queryKey: activeQueryKey, exact: true }, { revert: false }) + + const currentRows = queryClient.getQueryData(activeQueryKey) ?? [] + const previousIndex = currentRows.findIndex( + (row) => normalizeInviteKey(row.id) === normalizeInviteKey(userId), + ) + return { + queryKey: activeQueryKey, + userId, + previousRow: previousIndex === -1 ? undefined : currentRows[previousIndex], + previousIndex, + } + } + + function rollback(change: OptimisticChange) { + const normalizedId = normalizeInviteKey(change.userId) + updateRows(change.queryKey, (currentRows) => { + const rowsWithoutUser = currentRows.filter( + (row) => normalizeInviteKey(row.id) !== normalizedId, + ) + if (!change.previousRow) return rowsWithoutUser + + const previousIndex = Math.min(change.previousIndex, rowsWithoutUser.length) + return [ + ...rowsWithoutUser.slice(0, previousIndex), + change.previousRow, + ...rowsWithoutUser.slice(previousIndex), + ] + }) + } + + function updateRows(activeQueryKey: MembersQueryKey, update: (rows: ShareRow[]) => ShareRow[]) { + queryClient.setQueryData(activeQueryKey, (currentRows = []) => update(currentRows)) + } + + 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 preserveRowOrder(rows: ShareRow[], previousRows: ShareRow[]) { + const rowsById = new Map(rows.map((row) => [normalizeInviteKey(row.id), row])) + const orderedRows = previousRows.flatMap((previousRow) => { + const id = normalizeInviteKey(previousRow.id) + const row = rowsById.get(id) + if (!row) return [] + rowsById.delete(id) + return [row] + }) + return [...orderedRows, ...rowsById.values()] +} 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 000000000..408bd3c2c --- /dev/null +++ b/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts @@ -0,0 +1,228 @@ +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>>(null) + const updatePreviewLoaded = ref(false) + const unavailableReason = ref(null) + const availabilityCheckKey = ref(null) + const availabilityRefresh = ref(0) + let availabilityRequestId = 0 + let availabilityRequest: { + key: string + promise: Promise<{ + preview: Awaited> + error: unknown | null + }> + } | null = null + + const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null) + const wrongAccount = computed(() => { + if (auth.isReady && !auth.isReady.value) return false + if (!expectedUserId.value) return false + return auth.user.value?.id !== expectedUserId.value + }) + const actionsLocked = computed(() => wrongAccount.value) + const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null) + const signedOut = computed(() => !auth.session_token.value) + const managerUserId = computed(() => { + const attachment = instance.value?.shared_instance + if (!attachment) return null + if (attachment.role === 'owner') { + return actionsLocked.value ? (attachment.linked_user_id ?? null) : null + } + return attachment.manager_id ?? null + }) + const managerUserQuery = useUserQuery(managerUserId) + const manager = computed(() => { + const attachment = instance.value?.shared_instance + if (!attachment) return null + + if (attachment.server_manager_name) { + return { + type: 'server', + name: attachment.server_manager_name, + avatarUrl: attachment.server_manager_icon_url ?? undefined, + tintBy: attachment.server_manager_name, + } + } + + const user = managerUserQuery.data.value + if (!user) return null + return { + type: 'user', + name: user.username, + avatarUrl: user.avatar_url ?? undefined, + tintBy: user.id, + } + }) + const unavailableManager = computed(() => manager.value?.name ?? null) + + function reset() { + availabilityRequestId++ + availabilityRequest = null + availabilityCheckKey.value = null + updatePreview.value = null + updatePreviewLoaded.value = false + unavailableReason.value = null + } + + function refreshAvailability() { + availabilityCheckKey.value = null + updatePreviewLoaded.value = false + availabilityRefresh.value++ + } + + function setUnavailable(reason: SharedInstanceUnavailableReason | null) { + availabilityRequestId++ + availabilityRequest = null + availabilityCheckKey.value = null + updatePreview.value = null + updatePreviewLoaded.value = false + unavailableReason.value = reason + } + + async function checkAvailability(instanceId: string, key: string, throwError = false) { + const requestId = ++availabilityRequestId + let request = availabilityRequest + if (!request || request.key !== key) { + const promise = install_get_shared_instance_update_preview(instanceId).then( + (preview) => ({ preview, error: null }), + (error: unknown) => ({ preview: null, error }), + ) + request = { key, promise } + availabilityRequest = request + void promise.finally(() => { + if (availabilityRequest?.promise === promise) availabilityRequest = null + }) + } + + const result = await request.promise + if (!isCurrentRequest(requestId, instanceId, key)) return null + + if (result.error !== null) { + updatePreviewLoaded.value = false + if (isSharedInstanceUnavailableError(result.error)) { + updatePreview.value = null + unavailableReason.value = getSharedInstanceUnavailableReason(result.error) + } else if (!throwError) { + notifyError(result.error) + } + + if (throwError) throw result.error + return null + } + + updatePreview.value = result.preview + updatePreviewLoaded.value = true + unavailableReason.value = null + return result.preview + } + + async function refreshUpdatePreview() { + const instanceId = instance.value?.id + const userId = auth.user.value?.id + if (!instanceId || !userId) return null + + const key = `${instanceId}:${userId}` + availabilityCheckKey.value = key + return await checkAvailability(instanceId, key, true) + } + + function isCurrentRequest(requestId: number, instanceId: string, key: string) { + return ( + requestId === availabilityRequestId && + instance.value?.id === instanceId && + availabilityCheckKey.value === key + ) + } + + watch( + () => ({ + refresh: availabilityRefresh.value, + instanceId: instance.value?.id, + role: instance.value?.shared_instance?.role, + locked: actionsLocked.value, + offline: offline.value, + signedIn: !!auth.session_token.value, + userId: auth.user.value?.id ?? null, + authReady: auth.isReady?.value ?? true, + }), + async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => { + if (!instanceId || !role || locked || offline || !authReady || !signedIn || !userId) { + availabilityRequestId++ + availabilityRequest = null + availabilityCheckKey.value = null + updatePreview.value = null + updatePreviewLoaded.value = false + if (instanceId && role) unavailableReason.value = null + return + } + + const key = `${instanceId}:${userId}` + if (availabilityCheckKey.value === key) return + availabilityCheckKey.value = key + await checkAvailability(instanceId, key) + }, + { immediate: true }, + ) + + return { + actionsLocked, + shareActionsLocked, + unavailableReason, + unavailableManager, + manager, + updatePreview, + expectedUserId, + wrongAccount, + signedOut, + reset, + refreshAvailability, + refreshUpdatePreview, + setUnavailable, + } +} + +export type SharedInstanceState = ReturnType + +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/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 52c50fc96..e09914fcc 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -47,7 +47,7 @@
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 000000000..dee36ebf5 --- /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 8832c09c6..7c688c6fa 100644 --- a/apps/app-frontend/src/providers/setup/auth.ts +++ b/apps/app-frontend/src/providers/setup/auth.ts @@ -1,5 +1,11 @@ import type { Labrinth } from '@modrinth/api-client' -import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui' +import { + type AuthFlow, + type AuthProvider, + type AuthRequestOptions, + type AuthUser, + provideAuth, +} from '@modrinth/ui' import { computed, type Ref, ref, watchEffect } from 'vue' type AppCredentials = { @@ -9,7 +15,11 @@ type AppCredentials = { export function setupAuthProvider( credentials: Ref, - requestSignIn: (redirectPath: string) => void | Promise, + requestSignIn: ( + redirectPath: string, + flow?: AuthFlow, + options?: AuthRequestOptions, + ) => 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 591980575..2baa1edd0 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-frontend/vite.config.ts b/apps/app-frontend/vite.config.ts index d97b76d8f..5b5d80d17 100644 --- a/apps/app-frontend/vite.config.ts +++ b/apps/app-frontend/vite.config.ts @@ -94,7 +94,7 @@ export default defineConfig({ }, // to make use of `TAURI_ENV_DEBUG` and other env variables // https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands - envPrefix: ['VITE_', 'TAURI_', 'MODRINTH_'], + envPrefix: ['VITE_', 'TAURI_', 'MODRINTH_', 'SHARED_INSTANCES_'], build: { rolldownOptions: { onwarn(warning, defaultHandler) { diff --git a/apps/app/build.rs b/apps/app/build.rs index 777e94473..56f05012a 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -55,7 +55,6 @@ fn main() { InlinedPlugin::new() .commands(&[ "get_importable_instances", - "import_instance", "is_valid_importable_instance", "get_default_launcher_path", ]) @@ -148,6 +147,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", @@ -176,6 +180,14 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "reports", + InlinedPlugin::new() + .commands(&["reports_create"]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "instance", InlinedPlugin::new() @@ -211,6 +223,15 @@ fn main() { "instance_kill", "instance_edit", "instance_edit_icon", + "instance_share_can_current_user_use", + "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", ]) @@ -252,6 +273,14 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "users", + InlinedPlugin::new() + .commands(&["search_user"]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "utils", InlinedPlugin::new() @@ -276,7 +305,6 @@ fn main() { .commands(&[ "init_ads_window", "hide_ads_window", - "scroll_ads_window", "show_ads_window", "show_ads_consent_overlay", "show_ads_consent_preferences", diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index 2cb252176..33dcb58f5 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -102,11 +102,13 @@ "instance:default", "install:default", "process:default", + "reports:default", "cache:default", "files:default", "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 96d2f0481..22dcbef21 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> { + Ok( + theseus::instance::get_shared_instance_update_preview(&instance_id) + .await?, + ) +} + +#[tauri::command] +pub async fn install_shared_instance( + shared_instance_id: String, + name: String, + manager_id: Option, + server_manager_name: Option, + server_manager_icon_url: Option, + instance_icon_url: Option, +) -> Result { + Ok(theseus::instance::install_shared_instance( + &shared_instance_id, + name, + manager_id, + server_manager_name, + server_manager_icon_url, + instance_icon_url, + ) + .await?) +} + +#[tauri::command] +pub async fn install_update_shared_instance( + instance_id: String, +) -> Result { + Ok(theseus::instance::update_shared_instance(&instance_id).await?) +} + #[tauri::command] pub async fn install_import_instance( launcher_type: ImportLauncherType, diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs index 1b33adf62..3de25824f 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance.rs @@ -10,6 +10,8 @@ use theseus::data::{ EditInstance as CoreEditInstance, InstanceInstallCandidate, InstanceInstallTarget, InstanceLaunchOverridesPatch, InstanceLink as CoreInstanceLink, InstanceMetadata, LinkedModpackInfo, + SharedInstanceAttachment as CoreSharedInstanceAttachment, + SharedInstanceRole, }; use theseus::instance::InstallProjectWithDependenciesRequest; use theseus::instance::QuickPlayType; @@ -50,6 +52,15 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_kill, instance_edit, instance_edit_icon, + instance_share_can_current_user_use, + 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, ]) @@ -70,6 +81,8 @@ pub struct Instance { pub loader_version: Option, pub groups: Vec, pub link: Option, + pub shared_instance: Option, + pub quarantined: bool, pub update_channel: ReleaseChannel, pub created: chrono::DateTime, pub modified: chrono::DateTime, @@ -115,10 +128,40 @@ pub enum InstanceLink { active_instance_id: Option, }, SharedInstance { - shared_instance_id: String, + modpack_project_id: Option, + modpack_version_id: Option, }, } +#[derive(Serialize, Debug, Clone)] +pub struct SharedInstanceAttachment { + pub id: String, + pub role: SharedInstanceRole, + pub manager_id: Option, + pub server_manager_name: Option, + pub server_manager_icon_url: Option, + pub linked_user_id: Option, + pub status: String, + pub applied_version: Option, + pub latest_version: Option, +} + +impl From for SharedInstanceAttachment { + fn from(attachment: CoreSharedInstanceAttachment) -> Self { + Self { + id: attachment.id.to_string(), + role: attachment.role, + manager_id: attachment.manager_id, + server_manager_name: attachment.server_manager_name, + server_manager_icon_url: attachment.server_manager_icon_url, + linked_user_id: attachment.linked_user_id, + status: attachment.status.as_str().to_string(), + applied_version: attachment.applied_version, + latest_version: attachment.latest_version, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct EditInstance { pub name: Option, @@ -201,6 +244,8 @@ impl From for Instance { loader_version: metadata.applied_content_set.loader_version, groups: metadata.groups, link: InstanceLink::from_core(metadata.link), + shared_instance: metadata.shared_instance.map(Into::into), + quarantined: metadata.quarantined, update_channel: metadata.instance.update_channel, created: metadata.instance.created, modified: metadata.instance.modified, @@ -268,11 +313,13 @@ impl InstanceLink { .collect(), active_instance_id: active_instance_id.map(|id| id.to_string()), }), - CoreInstanceLink::SharedInstance { shared_instance_id } => { - Some(Self::SharedInstance { - shared_instance_id: shared_instance_id.to_string(), - }) - } + CoreInstanceLink::SharedInstance { + modpack_project_id, + modpack_version_id, + } => Some(Self::SharedInstance { + modpack_project_id, + modpack_version_id, + }), } } @@ -345,19 +392,13 @@ impl InstanceLink { }) .transpose()?, }), - Self::SharedInstance { shared_instance_id } => { - Ok(CoreInstanceLink::SharedInstance { - shared_instance_id: shared_instance_id.parse().map_err( - |err| { - theseus::Error::from( - theseus::ErrorKind::InputError(format!( - "Invalid shared instance id: {err}" - )), - ) - }, - )?, - }) - } + Self::SharedInstance { + modpack_project_id, + modpack_version_id, + } => Ok(CoreInstanceLink::SharedInstance { + modpack_project_id, + modpack_version_id, + }), } } } @@ -744,3 +785,90 @@ pub async fn instance_edit_icon( theseus::instance::edit_icon(instance_id, icon_path).await?; Ok(()) } + +#[tauri::command] +pub async fn instance_share_can_current_user_use() -> Result { + Ok(theseus::instance::can_active_user_use_shared_instances().await?) +} + +#[tauri::command] +pub async fn instance_share_get_users( + instance_id: &str, +) -> Result { + Ok(theseus::instance::get_shared_instance_users(instance_id).await?) +} + +#[tauri::command] +pub async fn instance_share_invite_users( + instance_id: &str, + user_ids: Vec, +) -> Result { + Ok( + theseus::instance::invite_shared_instance_users(instance_id, user_ids) + .await?, + ) +} + +#[tauri::command] +pub async fn instance_share_create_invite_link( + instance_id: &str, + max_age_seconds: Option, + max_uses: Option, + replace_invite_id: Option, +) -> Result { + Ok(theseus::instance::create_shared_instance_invite_link( + instance_id, + max_age_seconds, + max_uses, + replace_invite_id, + ) + .await?) +} + +#[tauri::command] +pub async fn instance_share_remove_users( + instance_id: &str, + user_ids: Vec, + has_pending_recipients: bool, +) -> Result { + Ok(theseus::instance::remove_shared_instance_users( + instance_id, + user_ids, + has_pending_recipients, + ) + .await?) +} + +#[tauri::command] +pub async fn instance_share_get_publish_preview( + instance_id: &str, +) -> Result> { + Ok( + theseus::instance::get_shared_instance_publish_preview(instance_id) + .await?, + ) +} + +#[tauri::command] +pub async fn instance_share_publish( + instance_id: &str, + config_paths: Vec, +) -> Result { + Ok( + theseus::instance::publish_shared_instance(instance_id, config_paths) + .await? + .into(), + ) +} + +#[tauri::command] +pub async fn instance_share_unlink(instance_id: &str) -> Result<()> { + theseus::instance::unlink_shared_instance(instance_id).await?; + Ok(()) +} + +#[tauri::command] +pub async fn instance_share_unpublish(instance_id: &str) -> Result<()> { + theseus::instance::unpublish_shared_instance(instance_id).await?; + Ok(()) +} diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 2d45357cb..acbe75b55 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -12,9 +12,11 @@ pub mod metadata; pub mod minecraft_skins; pub mod mr_auth; pub mod process; +pub mod reports; pub mod settings; pub mod shortcuts; pub mod tags; +pub mod users; pub mod utils; pub mod ads; @@ -85,9 +87,20 @@ macro_rules! impl_serialize { TheseusSerializableError::Theseus(theseus_error) => { $crate::error::display_tracing_error(theseus_error); - let mut state = serializer.serialize_struct("Theseus", 2)?; + let unavailable_reason = match theseus_error.raw.as_ref() { + theseus::ErrorKind::SharedInstanceUnavailable(reason) => Some(reason), + _ => None, + }; + let mut state = serializer.serialize_struct( + "Theseus", + if unavailable_reason.is_some() { 4 } else { 2 }, + )?; state.serialize_field("field_name", "Theseus")?; state.serialize_field("message", &theseus_error.to_string())?; + if let Some(reason) = unavailable_reason { + state.serialize_field("code", "shared_instance_unavailable")?; + state.serialize_field("reason", reason)?; + } state.end() } $( diff --git a/apps/app/src/api/mr_auth.rs b/apps/app/src/api/mr_auth.rs index 2143d20c5..5aba3132a 100644 --- a/apps/app/src/api/mr_auth.rs +++ b/apps/app/src/api/mr_auth.rs @@ -22,6 +22,7 @@ pub fn init() -> TauriPlugin { #[tauri::command] pub async fn modrinth_login( app: tauri::AppHandle, + flow: mr_auth::ModrinthAuthFlow, ) -> Result { let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel(); let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen( @@ -32,7 +33,7 @@ pub async fn modrinth_login( let auth_request_uri = format!( "{}?launcher=true&ipver={}&port={}", - mr_auth::authenticate_begin_flow(), + mr_auth::authenticate_begin_flow(flow), if auth_code_recv_socket.is_ipv4() { "4" } else { diff --git a/apps/app/src/api/reports.rs b/apps/app/src/api/reports.rs new file mode 100644 index 000000000..3ee5ceb10 --- /dev/null +++ b/apps/app/src/api/reports.rs @@ -0,0 +1,15 @@ +use crate::api::Result; +use theseus::reports::{CreateReportRequest, CreateReportResponse}; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("reports") + .invoke_handler(tauri::generate_handler![reports_create]) + .build() +} + +#[tauri::command] +pub async fn reports_create( + request: CreateReportRequest, +) -> Result { + Ok(theseus::reports::create_report(request).await?) +} diff --git a/apps/app/src/api/users.rs b/apps/app/src/api/users.rs new file mode 100644 index 000000000..b4c5dba75 --- /dev/null +++ b/apps/app/src/api/users.rs @@ -0,0 +1,13 @@ +use crate::api::Result; +use theseus::users::SearchUser; + +#[tauri::command] +pub async fn search_user(query: &str) -> Result> { + Ok(theseus::users::search_user(query).await?) +} + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("users") + .invoke_handler(tauri::generate_handler![search_user]) + .build() +} diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 96d6b9e33..2d70869a6 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -152,7 +152,7 @@ fn main() { builder = builder .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { if let Some(payload) = args.get(1) { - tracing::info!("Handling deep link from arg {payload}"); + tracing::info!("Handling command-line deep link"); let payload = payload.clone(); tauri::async_runtime::spawn(api::utils::handle_command( payload, @@ -197,7 +197,7 @@ fn main() { .unwrap_or(request); tauri::async_runtime::spawn(async move { - tracing::info!("Handling deep link {actual_request}"); + tracing::info!("Handling macOS deep link"); let mut payload = mtx_copy_copy.lock().await; if payload.is_none() { @@ -213,7 +213,7 @@ fn main() { #[cfg(not(target_os = "macos"))] app.listen("deep-link://new-url", |url| { let payload = url.payload().to_owned(); - tracing::info!("Handling deep link {payload}"); + tracing::info!("Handling deep link"); tauri::async_runtime::spawn(api::utils::handle_command( payload, )); @@ -240,9 +240,11 @@ fn main() { .plugin(api::metadata::init()) .plugin(api::minecraft_skins::init()) .plugin(api::process::init()) + .plugin(api::reports::init()) .plugin(api::settings::init()) .plugin(api::shortcuts::init()) .plugin(api::tags::init()) + .plugin(api::users::init()) .plugin(api::utils::init()) .plugin(api::cache::init()) .plugin(api::files::init()) diff --git a/apps/frontend/.env.local b/apps/frontend/.env.local index 29aad1d10..c41cb5f51 100644 --- a/apps/frontend/.env.local +++ b/apps/frontend/.env.local @@ -1,4 +1,5 @@ BASE_URL=http://127.0.0.1:8000/v2/ BROWSER_BASE_URL=http://127.0.0.1:8000/v2/ PYRO_BASE_URL=https://staging-archon.modrinth.com +SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com PROD_OVERRIDE=true diff --git a/apps/frontend/.env.prod b/apps/frontend/.env.prod index 8aebc57d5..6c1f4b15c 100644 --- a/apps/frontend/.env.prod +++ b/apps/frontend/.env.prod @@ -1,4 +1,5 @@ BASE_URL=https://api.modrinth.com/v2/ BROWSER_BASE_URL=https://api.modrinth.com/v2/ PYRO_BASE_URL=https://archon.modrinth.com +SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com PROD_OVERRIDE=true diff --git a/apps/frontend/.env.staging b/apps/frontend/.env.staging index 3dd5f73d3..59e9c8700 100644 --- a/apps/frontend/.env.staging +++ b/apps/frontend/.env.staging @@ -1,4 +1,5 @@ BASE_URL=https://staging-api.modrinth.com/v2/ BROWSER_BASE_URL=https://staging-api.modrinth.com/v2/ PYRO_BASE_URL=https://staging-archon.modrinth.com +SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com PROD_OVERRIDE=true diff --git a/apps/frontend/.env.staging-archon b/apps/frontend/.env.staging-archon index 4f733625a..b5f0713f7 100644 --- a/apps/frontend/.env.staging-archon +++ b/apps/frontend/.env.staging-archon @@ -1,4 +1,5 @@ BASE_URL=https://api.modrinth.com/v2/ BROWSER_BASE_URL=https://api.modrinth.com/v2/ PYRO_BASE_URL=https://staging-archon.modrinth.com +SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com PROD_OVERRIDE=true diff --git a/apps/frontend/nuxt.config.ts b/apps/frontend/nuxt.config.ts index 0d14d0c54..7322ac32a 100644 --- a/apps/frontend/nuxt.config.ts +++ b/apps/frontend/nuxt.config.ts @@ -7,6 +7,7 @@ import svgLoader from 'vite-svg-loader' import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts' const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/' +const STAGING_SHARED_INSTANCES_API_URL = 'https://staging-shared-instances.modrinth.com' const API_CLIENT_SOURCE = fileURLToPath( new URL('../../packages/api-client/src/index.ts', import.meta.url), ) @@ -210,6 +211,7 @@ export default defineNuxtConfig({ // @ts-ignore rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY, pyroBaseUrl: process.env.PYRO_BASE_URL, + sharedInstancesBaseUrl: getSharedInstancesApiUrl(), intercomIdentitySecret: process.env.INTERCOM_IDENTITY_SECRET ?? // @ts-ignore @@ -217,6 +219,7 @@ export default defineNuxtConfig({ public: { apiBaseUrl: getApiUrl(), pyroBaseUrl: process.env.PYRO_BASE_URL, + sharedInstancesBaseUrl: getSharedInstancesApiUrl(), siteUrl: getDomain(), intercomAppId: process.env.INTERCOM_APP_ID || @@ -354,6 +357,15 @@ function getApiUrl() { return process.env.BROWSER_BASE_URL ?? globalThis.BROWSER_BASE_URL ?? STAGING_API_URL } +function getSharedInstancesApiUrl() { + return ( + process.env.SHARED_INSTANCES_API_BASE_URL ?? + // @ts-ignore + globalThis.SHARED_INSTANCES_API_BASE_URL ?? + STAGING_SHARED_INSTANCES_API_URL + ) +} + function isProduction() { return process.env.NODE_ENV === 'production' } diff --git a/apps/frontend/src/components/ui/SharedInstanceInviteOpenInAppModal.vue b/apps/frontend/src/components/ui/SharedInstanceInviteOpenInAppModal.vue new file mode 100644 index 000000000..541bb86c2 --- /dev/null +++ b/apps/frontend/src/components/ui/SharedInstanceInviteOpenInAppModal.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue index e2c2b935f..aa10338c3 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue @@ -89,14 +89,18 @@
-
+
{{ reportItemTitle }} + + {{ reportItemTitle }} +
{{ report.version.files.find((f) => f.primary)?.filename || 'Unknown Version' }} + + Version {{ report.shared_instance_version_id }} + + + + + Quarantined +
@@ -135,6 +153,24 @@ {{ report.target.name }}
+
+ + + {{ sharedInstanceDetails.owner.username }} + +
@@ -154,6 +190,39 @@ :closed="reportClosed" @update-thread="updateThread" > + diff --git a/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue b/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue new file mode 100644 index 000000000..3f3b0d9ad --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue @@ -0,0 +1,442 @@ + + + + + diff --git a/apps/frontend/src/components/ui/report/ReportInfo.vue b/apps/frontend/src/components/ui/report/ReportInfo.vue index 3c4c619eb..3f584a0d5 100644 --- a/apps/frontend/src/components/ui/report/ReportInfo.vue +++ b/apps/frontend/src/components/ui/report/ReportInfo.vue @@ -63,6 +63,18 @@
+
+
+ +
+
+ Shared instance + + Version {{ report.shared_instance_version_id ?? 'unknown' }} · + + +
+
@@ -106,7 +118,7 @@ + + diff --git a/apps/frontend/src/providers/setup/modrinth-client.ts b/apps/frontend/src/providers/setup/modrinth-client.ts index 0f5d13ede..a6f2c8911 100644 --- a/apps/frontend/src/providers/setup/modrinth-client.ts +++ b/apps/frontend/src/providers/setup/modrinth-client.ts @@ -7,6 +7,7 @@ export function setupModrinthClientProvider(auth: Awaited AbstractModule @@ -128,6 +132,10 @@ export const MODULE_REGISTRY = { labrinth_versions_v3: LabrinthVersionsV3Module, paper_versions_v3: PaperVersionsV3Module, purpur_versions_v2: PurpurVersionsV2Module, + sharedinstances_invites_v1: SharedInstancesInvitesV1Module, + sharedinstances_instances_v1: SharedInstancesInstancesV1Module, + sharedinstances_moderation_v1: SharedInstancesModerationV1Module, + sharedinstances_users_v1: SharedInstancesUsersV1Module, } as const satisfies Record export type ModuleID = keyof typeof MODULE_REGISTRY diff --git a/packages/api-client/src/modules/labrinth/images/v3.ts b/packages/api-client/src/modules/labrinth/images/v3.ts index 7613f0deb..c9e058523 100644 --- a/packages/api-client/src/modules/labrinth/images/v3.ts +++ b/packages/api-client/src/modules/labrinth/images/v3.ts @@ -21,7 +21,7 @@ function buildImageQueryParams( params.thread_message_id = target.thread_message_id break case 'report': - params.report_id = target.report_id + if (target.report_id) params.report_id = target.report_id break } return params diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 6e823c614..9789cc6df 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -434,7 +434,7 @@ export namespace Labrinth { | { context: 'project'; project_id: string } | { context: 'version'; version_id: string } | { context: 'thread_message'; thread_message_id: string } - | { context: 'report'; report_id: string } + | { context: 'report'; report_id: string | null } ) export type UploadedImageFor = Extract< @@ -450,7 +450,7 @@ export namespace Labrinth { | { context: 'project'; project_id: string } | { context: 'version'; version_id: string } | { context: 'thread_message'; thread_message_id: string } - | { context: 'report'; report_id: string } + | { context: 'report'; report_id?: string } } } @@ -1917,13 +1917,14 @@ export namespace Labrinth { export namespace Reports { export namespace v3 { - export type ItemType = 'project' | 'version' | 'user' | 'unknown' + export type ItemType = 'project' | 'version' | 'user' | 'shared-instance' | 'unknown' export type Report = { id: string report_type: string item_id: string item_type: ItemType + shared_instance_version_id?: number reporter: string body: string created: string diff --git a/packages/api-client/src/modules/shared-instances/instances/v1.ts b/packages/api-client/src/modules/shared-instances/instances/v1.ts new file mode 100644 index 000000000..7b9581046 --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/instances/v1.ts @@ -0,0 +1,66 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { SharedInstances } from '../types' + +export class SharedInstancesInstancesV1Module extends AbstractModule { + public getModuleID(): string { + return 'sharedinstances_instances_v1' + } + + public async get(instanceId: string): Promise { + return this.client.request( + `/instances/${encodeURIComponent(instanceId)}`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } + + public async getForUser(userId: string): Promise { + return this.client.request('/instances', { + api: 'sharedinstances', + version: 1, + method: 'GET', + params: { user: userId }, + }) + } + + public async getUsers(instanceId: string): Promise { + return this.client.request( + `/instances/${encodeURIComponent(instanceId)}/users`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } + + public async getLatestVersion( + instanceId: string, + ): Promise { + return this.client.request( + `/instances/${encodeURIComponent(instanceId)}/versions`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } + + public async getVersion( + instanceId: string, + version: number, + ): Promise { + return this.client.request( + `/instances/${encodeURIComponent(instanceId)}/versions/${version}`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } +} diff --git a/packages/api-client/src/modules/shared-instances/invites/v1.ts b/packages/api-client/src/modules/shared-instances/invites/v1.ts new file mode 100644 index 000000000..72536cd0a --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/invites/v1.ts @@ -0,0 +1,21 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { SharedInstances } from '../types' + +export class SharedInstancesInvitesV1Module extends AbstractModule { + public getModuleID(): string { + return 'sharedinstances_invites_v1' + } + + public async get(inviteId: string): Promise { + return this.client.request( + `/invites/${encodeURIComponent(inviteId)}`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + skipAuth: true, + retry: false, + }, + ) + } +} diff --git a/packages/api-client/src/modules/shared-instances/moderation/v1.ts b/packages/api-client/src/modules/shared-instances/moderation/v1.ts new file mode 100644 index 000000000..b70056da9 --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/moderation/v1.ts @@ -0,0 +1,41 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { SharedInstances } from '../types' + +export class SharedInstancesModerationV1Module extends AbstractModule { + public getModuleID(): string { + return 'sharedinstances_moderation_v1' + } + + public async blacklistUsers( + request: SharedInstances.Moderation.v1.BlacklistUserRequest, + ): Promise { + return this.client.request('/moderation/blacklist', { + api: 'sharedinstances', + version: 1, + method: 'POST', + body: request, + }) + } + + public async unblacklistUsers( + request: SharedInstances.Moderation.v1.BlacklistUserRequest, + ): Promise { + return this.client.request('/moderation/blacklist', { + api: 'sharedinstances', + version: 1, + method: 'DELETE', + body: request, + }) + } + + public async deleteFile(instanceId: string, version: number, fileName: string): Promise { + return this.client.request( + `/moderation/instances/${encodeURIComponent(instanceId)}/versions/${version}/files/${encodeURIComponent(fileName)}`, + { + api: 'sharedinstances', + version: 1, + method: 'DELETE', + }, + ) + } +} diff --git a/packages/api-client/src/modules/shared-instances/types.ts b/packages/api-client/src/modules/shared-instances/types.ts new file mode 100644 index 000000000..4ad1e3afb --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/types.ts @@ -0,0 +1,101 @@ +export namespace SharedInstances { + export namespace Invites { + export namespace v1 { + export type UserManager = { + id: string + name: string + type: 'user' + avatar?: string | null + } + + export type ServerManager = { + name: string + type: 'server' + icon?: string | null + } + + export type Manager = UserManager | ServerManager + + export type InviteUser = { + id: string + name: string + avatar?: string | null + joined_at: string | null + } + + export type Invite = { + instance_id: string + instance_name: string + instance_icon?: string | null + game_version: string + loader_version: string + managers: Manager[] + instance_users?: InviteUser[] + } + } + } + + export namespace Instances { + export namespace v1 { + export type Instance = { + name: string + icon: string | null + quarantine: boolean + } + + export type JoinType = 'owner' | 'invite' | 'link' + + export type InstanceUser = { + id: string + joined_at: string | null + join_type: JoinType + last_played: string | null + } + + export type InstanceUsers = { + users: InstanceUser[] + tokens: number + } + + export type FileMetadata = { + path: string + hash: string + } + + export type ExternalFile = { + file_name: string + file_type: string + url: string + file_size?: number + metadata?: FileMetadata[] + } + + export type InstanceVersion = { + version: number + modrinth_ids?: string[] + ready: boolean + external_files: ExternalFile[] + modpack_id: string | null + game_version: string + loader: string + loader_version: string + } + } + } + + export namespace Moderation { + export namespace v1 { + export type BlacklistUserRequest = { + user_ids: string[] + } + } + } + + export namespace Users { + export namespace v1 { + export type BlacklistStatus = { + blacklisted: boolean + } + } + } +} diff --git a/packages/api-client/src/modules/shared-instances/users/v1.ts b/packages/api-client/src/modules/shared-instances/users/v1.ts new file mode 100644 index 000000000..fafed1965 --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/users/v1.ts @@ -0,0 +1,21 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { SharedInstances } from '../types' + +export class SharedInstancesUsersV1Module extends AbstractModule { + public getModuleID(): string { + return 'sharedinstances_users_v1' + } + + public async getBlacklistStatus( + userId: string, + ): Promise { + return this.client.request( + `/blacklist/${encodeURIComponent(userId)}`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } +} diff --git a/packages/api-client/src/modules/types.ts b/packages/api-client/src/modules/types.ts index d86e87723..bd25e5f70 100644 --- a/packages/api-client/src/modules/types.ts +++ b/packages/api-client/src/modules/types.ts @@ -6,3 +6,4 @@ export * from './launcher-meta/types' export * from './mclogs/types' export * from './paper/types' export * from './purpur/types' +export * from './shared-instances/types' diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts index 7d6016d5b..4bef70372 100644 --- a/packages/api-client/src/platform/xhr-upload-client.ts +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -21,6 +21,8 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) + } else if (options.api === 'sharedinstances') { + baseUrl = this.resolveBaseUrl(this.config.sharedInstancesBaseUrl!) } else { baseUrl = options.api } diff --git a/packages/api-client/src/types/client.ts b/packages/api-client/src/types/client.ts index 57d33105e..b2b991cc4 100644 --- a/packages/api-client/src/types/client.ts +++ b/packages/api-client/src/types/client.ts @@ -50,6 +50,12 @@ export interface ClientConfig { */ archonBaseUrl?: BaseUrlConfig + /** + * Base URL for the Shared Instances API + * @default 'https://shared-instances.modrinth.com' + */ + sharedInstancesBaseUrl?: BaseUrlConfig + /** * Default request timeout in milliseconds * @default 10000 diff --git a/packages/api-client/src/types/request.ts b/packages/api-client/src/types/request.ts index c1545e578..d1a57d884 100644 --- a/packages/api-client/src/types/request.ts +++ b/packages/api-client/src/types/request.ts @@ -11,6 +11,7 @@ export type RequestOptions = { * API to use for this request * - 'labrinth': Main Modrinth API (resolves to labrinthBaseUrl) * - 'archon': Modrinth Hosting API (resolves to archonBaseUrl) + * - 'sharedinstances': Shared Instances API (resolves to sharedInstancesBaseUrl) * - string: Custom base URL (e.g., 'https://custom-api.com') */ api: 'labrinth' | 'archon' | string diff --git a/packages/app-lib/.env.local b/packages/app-lib/.env.local index 198438ffe..0fa04b5dd 100644 --- a/packages/app-lib/.env.local +++ b/packages/app-lib/.env.local @@ -1,5 +1,6 @@ MODRINTH_URL=http://localhost:3000/ MODRINTH_API_BASE_URL=http://localhost:8000/ +SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/ MODRINTH_API_URL=http://127.0.0.1:8000/v2/ MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/ MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/ diff --git a/packages/app-lib/.env.prod b/packages/app-lib/.env.prod index 94f2c2d34..8459f8a61 100644 --- a/packages/app-lib/.env.prod +++ b/packages/app-lib/.env.prod @@ -1,5 +1,6 @@ MODRINTH_URL=https://modrinth.com/ MODRINTH_API_BASE_URL=https://api.modrinth.com/ +SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/ MODRINTH_ARCHON_BASE_URL=https://archon.modrinth.com/ MODRINTH_API_URL=https://api.modrinth.com/v2/ MODRINTH_API_URL_V3=https://api.modrinth.com/v3/ diff --git a/packages/app-lib/.env.prod-with-staging-archon b/packages/app-lib/.env.prod-with-staging-archon index b2d549c0e..d27b54338 100644 --- a/packages/app-lib/.env.prod-with-staging-archon +++ b/packages/app-lib/.env.prod-with-staging-archon @@ -1,5 +1,6 @@ MODRINTH_URL=https://modrinth.com/ MODRINTH_API_BASE_URL=https://api.modrinth.com/ +SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/ MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/ MODRINTH_API_URL=https://api.modrinth.com/v2/ MODRINTH_API_URL_V3=https://api.modrinth.com/v3/ diff --git a/packages/app-lib/.env.staging b/packages/app-lib/.env.staging index 0094b73e5..7b6fe6a17 100644 --- a/packages/app-lib/.env.staging +++ b/packages/app-lib/.env.staging @@ -1,5 +1,6 @@ MODRINTH_URL=https://staging.modrinth.com/ MODRINTH_API_BASE_URL=https://staging-api.modrinth.com/ +SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/ MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/ MODRINTH_API_URL=https://staging-api.modrinth.com/v2/ MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/ diff --git a/packages/app-lib/.sqlx/query-0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f.json b/packages/app-lib/.sqlx/query-0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f.json new file mode 100644 index 000000000..b4cb1c6ed --- /dev/null +++ b/packages/app-lib/.sqlx/query-0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f.json @@ -0,0 +1,296 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?", + "describe": { + "columns": [ + { + "name": "id!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "applied_content_set_id?: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "install_stage!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "launcher_feature_version!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "update_channel!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "icon_path?: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "created!: i64", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "modified!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_played?: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "submitted_time_played!: i64", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "recent_time_played!: i64", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "content_set_id?: String", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "content_set_instance_id?: String", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "content_set_name?: String", + "ordinal": 15, + "type_info": "Text" + }, + { + "name": "content_set_source_kind?: String", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "content_set_status?: String", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "content_set_game_version?: String", + "ordinal": 18, + "type_info": "Text" + }, + { + "name": "content_set_protocol_version?: i64", + "ordinal": 19, + "type_info": "Integer" + }, + { + "name": "content_set_loader?: String", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "content_set_loader_version?: String", + "ordinal": 21, + "type_info": "Text" + }, + { + "name": "content_set_created?: i64", + "ordinal": 22, + "type_info": "Integer" + }, + { + "name": "content_set_modified?: i64", + "ordinal": 23, + "type_info": "Integer" + }, + { + "name": "link_kind!: String", + "ordinal": 24, + "type_info": "Null" + }, + { + "name": "modrinth_project_id?: String", + "ordinal": 25, + "type_info": "Text" + }, + { + "name": "modrinth_version_id?: String", + "ordinal": 26, + "type_info": "Text" + }, + { + "name": "server_project_id?: String", + "ordinal": 27, + "type_info": "Text" + }, + { + "name": "content_project_id?: String", + "ordinal": 28, + "type_info": "Text" + }, + { + "name": "content_version_id?: String", + "ordinal": 29, + "type_info": "Text" + }, + { + "name": "hosting_server_id?: String", + "ordinal": 30, + "type_info": "Text" + }, + { + "name": "hosting_instance_ids?: String", + "ordinal": 31, + "type_info": "Null" + }, + { + "name": "hosting_active_instance_id?: String", + "ordinal": 32, + "type_info": "Text" + }, + { + "name": "shared_instance_id?: String", + "ordinal": 33, + "type_info": "Text" + }, + { + "name": "shared_instance_role?: String", + "ordinal": 34, + "type_info": "Text" + }, + { + "name": "shared_instance_manager_id?: String", + "ordinal": 35, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_name?: String", + "ordinal": 36, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_icon_url?: String", + "ordinal": 37, + "type_info": "Text" + }, + { + "name": "shared_instance_linked_user_id?: String", + "ordinal": 38, + "type_info": "Text" + }, + { + "name": "shared_sync_applied_update_id?: String", + "ordinal": 39, + "type_info": "Text" + }, + { + "name": "shared_sync_latest_available_update_id?: String", + "ordinal": 40, + "type_info": "Text" + }, + { + "name": "shared_sync_status?: String", + "ordinal": 41, + "type_info": "Text" + }, + { + "name": "imported_name?: String", + "ordinal": 42, + "type_info": "Text" + }, + { + "name": "imported_version_number?: String", + "ordinal": 43, + "type_info": "Text" + }, + { + "name": "imported_filename?: String", + "ordinal": 44, + "type_info": "Text" + }, + { + "name": "groups!: String", + "ordinal": 45, + "type_info": "Null" + }, + { + "name": "launch_overrides?: String", + "ordinal": 46, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + null, + true, + true, + true, + true, + true, + true, + null, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null, + null + ] + }, + "hash": "0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f" +} diff --git a/packages/app-lib/.sqlx/query-2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc.json b/packages/app-lib/.sqlx/query-2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc.json new file mode 100644 index 000000000..76d7ed3fc --- /dev/null +++ b/packages/app-lib/.sqlx/query-2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO instance_content_set_remote_refs (\n\t\t\tcontent_set_id,\n\t\t\tref_type,\n\t\t\tref_id\n\t\t)\n\t\tVALUES (?, ?, ?)\n\t\tON CONFLICT (content_set_id, ref_type) DO UPDATE SET\n\t\t\tref_id = excluded.ref_id\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc" +} diff --git a/packages/app-lib/.sqlx/query-474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7.json b/packages/app-lib/.sqlx/query-474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7.json deleted file mode 100644 index 14967a6a1..000000000 --- a/packages/app-lib/.sqlx/query-474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n '[]' AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ", - "describe": { - "columns": [ - { - "name": "id!: String", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "path!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "applied_content_set_id?: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "install_stage!: String", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "launcher_feature_version!: String", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "update_channel!: String", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "name!: String", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "icon_path?: String", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created!: i64", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "modified!: i64", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "last_played?: i64", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "submitted_time_played!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "recent_time_played!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "content_set_id?: String", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "content_set_instance_id?: String", - "ordinal": 14, - "type_info": "Text" - }, - { - "name": "content_set_name?: String", - "ordinal": 15, - "type_info": "Text" - }, - { - "name": "content_set_source_kind?: String", - "ordinal": 16, - "type_info": "Text" - }, - { - "name": "content_set_status?: String", - "ordinal": 17, - "type_info": "Text" - }, - { - "name": "content_set_game_version?: String", - "ordinal": 18, - "type_info": "Text" - }, - { - "name": "content_set_protocol_version?: i64", - "ordinal": 19, - "type_info": "Integer" - }, - { - "name": "content_set_loader?: String", - "ordinal": 20, - "type_info": "Text" - }, - { - "name": "content_set_loader_version?: String", - "ordinal": 21, - "type_info": "Text" - }, - { - "name": "content_set_created?: i64", - "ordinal": 22, - "type_info": "Integer" - }, - { - "name": "content_set_modified?: i64", - "ordinal": 23, - "type_info": "Integer" - }, - { - "name": "link_kind!: String", - "ordinal": 24, - "type_info": "Null" - }, - { - "name": "modrinth_project_id?: String", - "ordinal": 25, - "type_info": "Text" - }, - { - "name": "modrinth_version_id?: String", - "ordinal": 26, - "type_info": "Text" - }, - { - "name": "server_project_id?: String", - "ordinal": 27, - "type_info": "Text" - }, - { - "name": "content_project_id?: String", - "ordinal": 28, - "type_info": "Text" - }, - { - "name": "content_version_id?: String", - "ordinal": 29, - "type_info": "Text" - }, - { - "name": "hosting_server_id?: String", - "ordinal": 30, - "type_info": "Text" - }, - { - "name": "hosting_instance_ids?: String", - "ordinal": 31, - "type_info": "Null" - }, - { - "name": "hosting_active_instance_id?: String", - "ordinal": 32, - "type_info": "Text" - }, - { - "name": "shared_instance_id?: String", - "ordinal": 33, - "type_info": "Text" - }, - { - "name": "imported_name?: String", - "ordinal": 34, - "type_info": "Text" - }, - { - "name": "imported_version_number?: String", - "ordinal": 35, - "type_info": "Text" - }, - { - "name": "imported_filename?: String", - "ordinal": 36, - "type_info": "Text" - }, - { - "name": "groups!: String", - "ordinal": 37, - "type_info": "Null" - }, - { - "name": "launch_overrides?: String", - "ordinal": 38, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - true, - false, - false, - false, - false, - true, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - null, - true, - true, - true, - true, - true, - true, - null, - true, - true, - true, - true, - true, - null, - null - ] - }, - "hash": "474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7" -} diff --git a/packages/app-lib/.sqlx/query-6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57.json b/packages/app-lib/.sqlx/query-6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57.json new file mode 100644 index 000000000..ad566bf3d --- /dev/null +++ b/packages/app-lib/.sqlx/query-6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO instance_content_set_sync_state (\n\t\t\tcontent_set_id,\n\t\t\tprovider,\n\t\t\tapplied_update_id,\n\t\t\tlatest_available_update_id,\n\t\t\tchecked_at,\n\t\t\tstatus\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (content_set_id) DO UPDATE SET\n\t\t\tprovider = excluded.provider,\n\t\t\tapplied_update_id = excluded.applied_update_id,\n\t\t\tlatest_available_update_id = excluded.latest_available_update_id,\n\t\t\tchecked_at = excluded.checked_at,\n\t\t\tstatus = excluded.status\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57" +} diff --git a/packages/app-lib/.sqlx/query-26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281.json b/packages/app-lib/.sqlx/query-6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa.json similarity index 73% rename from packages/app-lib/.sqlx/query-26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281.json rename to packages/app-lib/.sqlx/query-6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa.json index d0a642ead..f7efeaa54 100644 --- a/packages/app-lib/.sqlx/query-26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281.json +++ b/packages/app-lib/.sqlx/query-6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ", + "query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n shared_instance_role,\n shared_instance_manager_id,\n shared_instance_linked_user_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ", "describe": { "columns": [ { @@ -59,19 +59,34 @@ "type_info": "Text" }, { - "name": "imported_name", + "name": "shared_instance_role", "ordinal": 11, "type_info": "Text" }, { - "name": "imported_version_number", + "name": "shared_instance_manager_id", "ordinal": 12, "type_info": "Text" }, { - "name": "imported_filename", + "name": "shared_instance_linked_user_id", "ordinal": 13, "type_info": "Text" + }, + { + "name": "imported_name", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "imported_version_number", + "ordinal": 15, + "type_info": "Text" + }, + { + "name": "imported_filename", + "ordinal": 16, + "type_info": "Text" } ], "parameters": { @@ -91,8 +106,11 @@ true, true, true, + true, + true, + true, true ] }, - "hash": "26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281" + "hash": "6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa" } diff --git a/packages/app-lib/.sqlx/query-76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652.json b/packages/app-lib/.sqlx/query-76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652.json new file mode 100644 index 000000000..b829fe372 --- /dev/null +++ b/packages/app-lib/.sqlx/query-76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tDELETE FROM instance_content_set_remote_refs\n\t\tWHERE content_set_id = ? AND ref_type = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652" +} diff --git a/packages/app-lib/.sqlx/query-88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a.json b/packages/app-lib/.sqlx/query-88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a.json new file mode 100644 index 000000000..9372caf04 --- /dev/null +++ b/packages/app-lib/.sqlx/query-88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a.json @@ -0,0 +1,296 @@ +{ + "db_name": "SQLite", + "query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n \n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n \n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n \n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord", + "describe": { + "columns": [ + { + "name": "id!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "applied_content_set_id?: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "install_stage!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "launcher_feature_version!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "update_channel!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "icon_path?: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "created!: i64", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "modified!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_played?: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "submitted_time_played!: i64", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "recent_time_played!: i64", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "content_set_id?: String", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "content_set_instance_id?: String", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "content_set_name?: String", + "ordinal": 15, + "type_info": "Text" + }, + { + "name": "content_set_source_kind?: String", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "content_set_status?: String", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "content_set_game_version?: String", + "ordinal": 18, + "type_info": "Text" + }, + { + "name": "content_set_protocol_version?: i64", + "ordinal": 19, + "type_info": "Integer" + }, + { + "name": "content_set_loader?: String", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "content_set_loader_version?: String", + "ordinal": 21, + "type_info": "Text" + }, + { + "name": "content_set_created?: i64", + "ordinal": 22, + "type_info": "Integer" + }, + { + "name": "content_set_modified?: i64", + "ordinal": 23, + "type_info": "Integer" + }, + { + "name": "link_kind!: String", + "ordinal": 24, + "type_info": "Null" + }, + { + "name": "modrinth_project_id?: String", + "ordinal": 25, + "type_info": "Text" + }, + { + "name": "modrinth_version_id?: String", + "ordinal": 26, + "type_info": "Text" + }, + { + "name": "server_project_id?: String", + "ordinal": 27, + "type_info": "Text" + }, + { + "name": "content_project_id?: String", + "ordinal": 28, + "type_info": "Text" + }, + { + "name": "content_version_id?: String", + "ordinal": 29, + "type_info": "Text" + }, + { + "name": "hosting_server_id?: String", + "ordinal": 30, + "type_info": "Text" + }, + { + "name": "hosting_instance_ids?: String", + "ordinal": 31, + "type_info": "Null" + }, + { + "name": "hosting_active_instance_id?: String", + "ordinal": 32, + "type_info": "Text" + }, + { + "name": "shared_instance_id?: String", + "ordinal": 33, + "type_info": "Text" + }, + { + "name": "shared_instance_role?: String", + "ordinal": 34, + "type_info": "Text" + }, + { + "name": "shared_instance_manager_id?: String", + "ordinal": 35, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_name?: String", + "ordinal": 36, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_icon_url?: String", + "ordinal": 37, + "type_info": "Text" + }, + { + "name": "shared_instance_linked_user_id?: String", + "ordinal": 38, + "type_info": "Text" + }, + { + "name": "shared_sync_applied_update_id?: String", + "ordinal": 39, + "type_info": "Text" + }, + { + "name": "shared_sync_latest_available_update_id?: String", + "ordinal": 40, + "type_info": "Text" + }, + { + "name": "shared_sync_status?: String", + "ordinal": 41, + "type_info": "Text" + }, + { + "name": "imported_name?: String", + "ordinal": 42, + "type_info": "Text" + }, + { + "name": "imported_version_number?: String", + "ordinal": 43, + "type_info": "Text" + }, + { + "name": "imported_filename?: String", + "ordinal": 44, + "type_info": "Text" + }, + { + "name": "groups!: String", + "ordinal": 45, + "type_info": "Null" + }, + { + "name": "launch_overrides?: String", + "ordinal": 46, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + null, + true, + true, + true, + true, + true, + true, + null, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null, + null + ] + }, + "hash": "88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a" +} diff --git a/packages/app-lib/.sqlx/query-aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04.json b/packages/app-lib/.sqlx/query-aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04.json new file mode 100644 index 000000000..0e3aa4541 --- /dev/null +++ b/packages/app-lib/.sqlx/query-aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04.json @@ -0,0 +1,296 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE 1 = ?", + "describe": { + "columns": [ + { + "name": "id!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "applied_content_set_id?: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "install_stage!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "launcher_feature_version!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "update_channel!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "icon_path?: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "created!: i64", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "modified!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_played?: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "submitted_time_played!: i64", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "recent_time_played!: i64", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "content_set_id?: String", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "content_set_instance_id?: String", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "content_set_name?: String", + "ordinal": 15, + "type_info": "Text" + }, + { + "name": "content_set_source_kind?: String", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "content_set_status?: String", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "content_set_game_version?: String", + "ordinal": 18, + "type_info": "Text" + }, + { + "name": "content_set_protocol_version?: i64", + "ordinal": 19, + "type_info": "Integer" + }, + { + "name": "content_set_loader?: String", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "content_set_loader_version?: String", + "ordinal": 21, + "type_info": "Text" + }, + { + "name": "content_set_created?: i64", + "ordinal": 22, + "type_info": "Integer" + }, + { + "name": "content_set_modified?: i64", + "ordinal": 23, + "type_info": "Integer" + }, + { + "name": "link_kind!: String", + "ordinal": 24, + "type_info": "Text" + }, + { + "name": "modrinth_project_id?: String", + "ordinal": 25, + "type_info": "Text" + }, + { + "name": "modrinth_version_id?: String", + "ordinal": 26, + "type_info": "Text" + }, + { + "name": "server_project_id?: String", + "ordinal": 27, + "type_info": "Text" + }, + { + "name": "content_project_id?: String", + "ordinal": 28, + "type_info": "Text" + }, + { + "name": "content_version_id?: String", + "ordinal": 29, + "type_info": "Text" + }, + { + "name": "hosting_server_id?: String", + "ordinal": 30, + "type_info": "Text" + }, + { + "name": "hosting_instance_ids?: String", + "ordinal": 31, + "type_info": "Null" + }, + { + "name": "hosting_active_instance_id?: String", + "ordinal": 32, + "type_info": "Text" + }, + { + "name": "shared_instance_id?: String", + "ordinal": 33, + "type_info": "Text" + }, + { + "name": "shared_instance_role?: String", + "ordinal": 34, + "type_info": "Text" + }, + { + "name": "shared_instance_manager_id?: String", + "ordinal": 35, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_name?: String", + "ordinal": 36, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_icon_url?: String", + "ordinal": 37, + "type_info": "Text" + }, + { + "name": "shared_instance_linked_user_id?: String", + "ordinal": 38, + "type_info": "Text" + }, + { + "name": "shared_sync_applied_update_id?: String", + "ordinal": 39, + "type_info": "Text" + }, + { + "name": "shared_sync_latest_available_update_id?: String", + "ordinal": 40, + "type_info": "Text" + }, + { + "name": "shared_sync_status?: String", + "ordinal": 41, + "type_info": "Text" + }, + { + "name": "imported_name?: String", + "ordinal": 42, + "type_info": "Text" + }, + { + "name": "imported_version_number?: String", + "ordinal": 43, + "type_info": "Text" + }, + { + "name": "imported_filename?: String", + "ordinal": 44, + "type_info": "Text" + }, + { + "name": "groups!: String", + "ordinal": 45, + "type_info": "Text" + }, + { + "name": "launch_overrides?: String", + "ordinal": 46, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + null, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null + ] + }, + "hash": "aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04" +} diff --git a/packages/app-lib/.sqlx/query-b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c.json b/packages/app-lib/.sqlx/query-b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c.json new file mode 100644 index 000000000..1f5b4f5af --- /dev/null +++ b/packages/app-lib/.sqlx/query-b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tshared_instance_id,\n\t\t\tshared_instance_role,\n\t\t\tshared_instance_manager_id,\n\t\t\tshared_instance_server_manager_name,\n\t\t\tshared_instance_server_manager_icon_url,\n\t\t\tshared_instance_linked_user_id\n\t\t)\n\t\tVALUES (?, 'unmanaged', ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = CASE\n\t\t\t\tWHEN excluded.shared_instance_id IS NULL\n\t\t\t\t\tAND instance_links.link_kind = 'shared_instance'\n\t\t\t\t\tTHEN 'unmanaged'\n\t\t\t\tELSE instance_links.link_kind\n\t\t\tEND,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\tshared_instance_role = excluded.shared_instance_role,\n\t\t\tshared_instance_manager_id = excluded.shared_instance_manager_id,\n\t\t\tshared_instance_server_manager_name = excluded.shared_instance_server_manager_name,\n\t\t\tshared_instance_server_manager_icon_url = excluded.shared_instance_server_manager_icon_url,\n\t\t\tshared_instance_linked_user_id = excluded.shared_instance_linked_user_id\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c" +} diff --git a/packages/app-lib/.sqlx/query-bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d.json b/packages/app-lib/.sqlx/query-bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d.json deleted file mode 100644 index 3b13db920..000000000 --- a/packages/app-lib/.sqlx/query-bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ", - "describe": { - "columns": [ - { - "name": "id!: String", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "path!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "applied_content_set_id?: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "install_stage!: String", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "launcher_feature_version!: String", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "update_channel!: String", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "name!: String", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "icon_path?: String", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created!: i64", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "modified!: i64", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "last_played?: i64", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "submitted_time_played!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "recent_time_played!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "content_set_id?: String", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "content_set_instance_id?: String", - "ordinal": 14, - "type_info": "Text" - }, - { - "name": "content_set_name?: String", - "ordinal": 15, - "type_info": "Text" - }, - { - "name": "content_set_source_kind?: String", - "ordinal": 16, - "type_info": "Text" - }, - { - "name": "content_set_status?: String", - "ordinal": 17, - "type_info": "Text" - }, - { - "name": "content_set_game_version?: String", - "ordinal": 18, - "type_info": "Text" - }, - { - "name": "content_set_protocol_version?: i64", - "ordinal": 19, - "type_info": "Integer" - }, - { - "name": "content_set_loader?: String", - "ordinal": 20, - "type_info": "Text" - }, - { - "name": "content_set_loader_version?: String", - "ordinal": 21, - "type_info": "Text" - }, - { - "name": "content_set_created?: i64", - "ordinal": 22, - "type_info": "Integer" - }, - { - "name": "content_set_modified?: i64", - "ordinal": 23, - "type_info": "Integer" - }, - { - "name": "link_kind!: String", - "ordinal": 24, - "type_info": "Null" - }, - { - "name": "modrinth_project_id?: String", - "ordinal": 25, - "type_info": "Text" - }, - { - "name": "modrinth_version_id?: String", - "ordinal": 26, - "type_info": "Text" - }, - { - "name": "server_project_id?: String", - "ordinal": 27, - "type_info": "Text" - }, - { - "name": "content_project_id?: String", - "ordinal": 28, - "type_info": "Text" - }, - { - "name": "content_version_id?: String", - "ordinal": 29, - "type_info": "Text" - }, - { - "name": "hosting_server_id?: String", - "ordinal": 30, - "type_info": "Text" - }, - { - "name": "hosting_instance_ids?: String", - "ordinal": 31, - "type_info": "Null" - }, - { - "name": "hosting_active_instance_id?: String", - "ordinal": 32, - "type_info": "Text" - }, - { - "name": "shared_instance_id?: String", - "ordinal": 33, - "type_info": "Text" - }, - { - "name": "imported_name?: String", - "ordinal": 34, - "type_info": "Text" - }, - { - "name": "imported_version_number?: String", - "ordinal": 35, - "type_info": "Text" - }, - { - "name": "imported_filename?: String", - "ordinal": 36, - "type_info": "Text" - }, - { - "name": "groups!: String", - "ordinal": 37, - "type_info": "Null" - }, - { - "name": "launch_overrides?: String", - "ordinal": 38, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - true, - false, - false, - false, - false, - true, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - null, - true, - true, - true, - true, - true, - true, - null, - true, - true, - true, - true, - true, - null, - null - ] - }, - "hash": "bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d" -} diff --git a/packages/app-lib/.sqlx/query-becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243.json b/packages/app-lib/.sqlx/query-becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243.json new file mode 100644 index 000000000..809392531 --- /dev/null +++ b/packages/app-lib/.sqlx/query-becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tDELETE FROM instance_content_set_sync_state\n\t\tWHERE content_set_id = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243" +} diff --git a/packages/app-lib/.sqlx/query-bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b.json b/packages/app-lib/.sqlx/query-bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b.json new file mode 100644 index 000000000..c26399515 --- /dev/null +++ b/packages/app-lib/.sqlx/query-bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b" +} diff --git a/packages/app-lib/.sqlx/query-e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306.json b/packages/app-lib/.sqlx/query-e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306.json deleted file mode 100644 index def78302d..000000000 --- a/packages/app-lib/.sqlx/query-e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord\n ", - "describe": { - "columns": [ - { - "name": "id!: String", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "path!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "applied_content_set_id?: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "install_stage!: String", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "launcher_feature_version!: String", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "update_channel!: String", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "name!: String", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "icon_path?: String", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created!: i64", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "modified!: i64", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "last_played?: i64", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "submitted_time_played!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "recent_time_played!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "content_set_id?: String", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "content_set_instance_id?: String", - "ordinal": 14, - "type_info": "Text" - }, - { - "name": "content_set_name?: String", - "ordinal": 15, - "type_info": "Text" - }, - { - "name": "content_set_source_kind?: String", - "ordinal": 16, - "type_info": "Text" - }, - { - "name": "content_set_status?: String", - "ordinal": 17, - "type_info": "Text" - }, - { - "name": "content_set_game_version?: String", - "ordinal": 18, - "type_info": "Text" - }, - { - "name": "content_set_protocol_version?: i64", - "ordinal": 19, - "type_info": "Integer" - }, - { - "name": "content_set_loader?: String", - "ordinal": 20, - "type_info": "Text" - }, - { - "name": "content_set_loader_version?: String", - "ordinal": 21, - "type_info": "Text" - }, - { - "name": "content_set_created?: i64", - "ordinal": 22, - "type_info": "Integer" - }, - { - "name": "content_set_modified?: i64", - "ordinal": 23, - "type_info": "Integer" - }, - { - "name": "link_kind!: String", - "ordinal": 24, - "type_info": "Null" - }, - { - "name": "modrinth_project_id?: String", - "ordinal": 25, - "type_info": "Text" - }, - { - "name": "modrinth_version_id?: String", - "ordinal": 26, - "type_info": "Text" - }, - { - "name": "server_project_id?: String", - "ordinal": 27, - "type_info": "Text" - }, - { - "name": "content_project_id?: String", - "ordinal": 28, - "type_info": "Text" - }, - { - "name": "content_version_id?: String", - "ordinal": 29, - "type_info": "Text" - }, - { - "name": "hosting_server_id?: String", - "ordinal": 30, - "type_info": "Text" - }, - { - "name": "hosting_instance_ids?: String", - "ordinal": 31, - "type_info": "Null" - }, - { - "name": "hosting_active_instance_id?: String", - "ordinal": 32, - "type_info": "Text" - }, - { - "name": "shared_instance_id?: String", - "ordinal": 33, - "type_info": "Text" - }, - { - "name": "imported_name?: String", - "ordinal": 34, - "type_info": "Text" - }, - { - "name": "imported_version_number?: String", - "ordinal": 35, - "type_info": "Text" - }, - { - "name": "imported_filename?: String", - "ordinal": 36, - "type_info": "Text" - }, - { - "name": "groups!: String", - "ordinal": 37, - "type_info": "Null" - }, - { - "name": "launch_overrides?: String", - "ordinal": 38, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - true, - false, - false, - false, - false, - true, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - null, - true, - true, - true, - true, - true, - true, - null, - true, - true, - true, - true, - true, - null, - null - ] - }, - "hash": "e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306" -} diff --git a/packages/app-lib/.sqlx/query-eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8.json b/packages/app-lib/.sqlx/query-eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8.json deleted file mode 100644 index 1086063a2..000000000 --- a/packages/app-lib/.sqlx/query-eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\tshared_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8" -} diff --git a/packages/app-lib/.sqlx/query-f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b.json b/packages/app-lib/.sqlx/query-f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b.json deleted file mode 100644 index 4fcd16770..000000000 --- a/packages/app-lib/.sqlx/query-f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ", - "describe": { - "columns": [ - { - "name": "id!: String", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "path!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "applied_content_set_id?: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "install_stage!: String", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "launcher_feature_version!: String", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "update_channel!: String", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "name!: String", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "icon_path?: String", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created!: i64", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "modified!: i64", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "last_played?: i64", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "submitted_time_played!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "recent_time_played!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "content_set_id?: String", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "content_set_instance_id?: String", - "ordinal": 14, - "type_info": "Text" - }, - { - "name": "content_set_name?: String", - "ordinal": 15, - "type_info": "Text" - }, - { - "name": "content_set_source_kind?: String", - "ordinal": 16, - "type_info": "Text" - }, - { - "name": "content_set_status?: String", - "ordinal": 17, - "type_info": "Text" - }, - { - "name": "content_set_game_version?: String", - "ordinal": 18, - "type_info": "Text" - }, - { - "name": "content_set_protocol_version?: i64", - "ordinal": 19, - "type_info": "Integer" - }, - { - "name": "content_set_loader?: String", - "ordinal": 20, - "type_info": "Text" - }, - { - "name": "content_set_loader_version?: String", - "ordinal": 21, - "type_info": "Text" - }, - { - "name": "content_set_created?: i64", - "ordinal": 22, - "type_info": "Integer" - }, - { - "name": "content_set_modified?: i64", - "ordinal": 23, - "type_info": "Integer" - }, - { - "name": "link_kind!: String", - "ordinal": 24, - "type_info": "Text" - }, - { - "name": "modrinth_project_id?: String", - "ordinal": 25, - "type_info": "Text" - }, - { - "name": "modrinth_version_id?: String", - "ordinal": 26, - "type_info": "Text" - }, - { - "name": "server_project_id?: String", - "ordinal": 27, - "type_info": "Text" - }, - { - "name": "content_project_id?: String", - "ordinal": 28, - "type_info": "Text" - }, - { - "name": "content_version_id?: String", - "ordinal": 29, - "type_info": "Text" - }, - { - "name": "hosting_server_id?: String", - "ordinal": 30, - "type_info": "Text" - }, - { - "name": "hosting_instance_ids?: String", - "ordinal": 31, - "type_info": "Null" - }, - { - "name": "hosting_active_instance_id?: String", - "ordinal": 32, - "type_info": "Text" - }, - { - "name": "shared_instance_id?: String", - "ordinal": 33, - "type_info": "Text" - }, - { - "name": "imported_name?: String", - "ordinal": 34, - "type_info": "Text" - }, - { - "name": "imported_version_number?: String", - "ordinal": 35, - "type_info": "Text" - }, - { - "name": "imported_filename?: String", - "ordinal": 36, - "type_info": "Text" - }, - { - "name": "groups!: String", - "ordinal": 37, - "type_info": "Text" - }, - { - "name": "launch_overrides?: String", - "ordinal": 38, - "type_info": "Null" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - true, - false, - false, - false, - false, - true, - false, - false, - true, - false, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - false, - true, - true, - true, - true, - true, - true, - null, - true, - true, - true, - true, - true, - false, - null - ] - }, - "hash": "f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b" -} diff --git a/packages/app-lib/migrations/20260630120000_shared-instance-role.sql b/packages/app-lib/migrations/20260630120000_shared-instance-role.sql new file mode 100644 index 000000000..0ee944c28 --- /dev/null +++ b/packages/app-lib/migrations/20260630120000_shared-instance-role.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_links + ADD COLUMN shared_instance_role TEXT NULL; diff --git a/packages/app-lib/migrations/20260707103000_shared-instance-manager.sql b/packages/app-lib/migrations/20260707103000_shared-instance-manager.sql new file mode 100644 index 000000000..beadec3d2 --- /dev/null +++ b/packages/app-lib/migrations/20260707103000_shared-instance-manager.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_links + ADD COLUMN shared_instance_manager_id TEXT NULL; diff --git a/packages/app-lib/migrations/20260707110000_shared-instance-linked-user.sql b/packages/app-lib/migrations/20260707110000_shared-instance-linked-user.sql new file mode 100644 index 000000000..b4fa60ab5 --- /dev/null +++ b/packages/app-lib/migrations/20260707110000_shared-instance-linked-user.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_links + ADD COLUMN shared_instance_linked_user_id TEXT NULL; diff --git a/packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql b/packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql new file mode 100644 index 000000000..066d18f2f --- /dev/null +++ b/packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql @@ -0,0 +1,5 @@ +ALTER TABLE instance_links + ADD COLUMN shared_instance_server_manager_name TEXT NULL; + +ALTER TABLE instance_links + ADD COLUMN shared_instance_server_manager_icon_url TEXT NULL; diff --git a/packages/app-lib/migrations/20260723120000_quarantined-instances.sql b/packages/app-lib/migrations/20260723120000_quarantined-instances.sql new file mode 100644 index 000000000..0fa1e1103 --- /dev/null +++ b/packages/app-lib/migrations/20260723120000_quarantined-instances.sql @@ -0,0 +1,4 @@ +CREATE TABLE instance_quarantines ( + instance_id TEXT PRIMARY KEY NOT NULL, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); diff --git a/packages/app-lib/src/api/handler.rs b/packages/app-lib/src/api/handler.rs index 8a5351c03..b35a0bf2b 100644 --- a/packages/app-lib/src/api/handler.rs +++ b/packages/app-lib/src/api/handler.rs @@ -30,6 +30,26 @@ pub async fn handle_url(sublink: &str) -> crate::Result { Some(("server", id)) => { CommandPayload::InstallServer { id: id.to_string() } } + // /share/{invite_id} + Some(("share", raw)) => { + let (raw, _) = raw.split_once('?').unwrap_or((raw, "")); + + match decode(raw) { + Ok(decoded) => CommandPayload::InstallSharedInstanceInvite { + invite_id: decoded.to_string(), + }, + Err(e) => { + emit_warning(&format!( + "Invalid UTF-8 in shared instance invite path: {e}" + )) + .await?; + return Err(crate::ErrorKind::InputError(format!( + "Invalid UTF-8 in shared instance invite path: {e}" + )) + .into()); + } + } + } // /launch/instance/{id} - Launches an instance Some(("launch", rest)) if rest.starts_with("instance/") => { let raw = rest.trim_start_matches("instance/"); @@ -93,7 +113,7 @@ pub async fn handle_url(sublink: &str) -> crate::Result { pub async fn parse_command( command_string: &str, ) -> crate::Result { - tracing::debug!("Parsing command: {}", &command_string); + tracing::debug!("Parsing external command"); // modrinth://some-command // This occurs when following a web redirect link diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs index bcaf873f8..ad30a0780 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance.rs @@ -1,6 +1,7 @@ //! Theseus instance management interface mod content; +mod content_set_diff; mod export_mrpack; mod get; mod install; @@ -8,6 +9,7 @@ mod lifecycle; mod paths; mod projects; mod run; +mod shared; pub use self::content::{ get_content_items, get_dependencies_as_content_items, @@ -33,3 +35,24 @@ pub use self::projects::{ pub use self::run::{ QuickPlayType, kill, run, try_update_playtime_by_instance_id, }; +pub(crate) use self::shared::{ + CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS, + CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES, + read_bounded_config_bundle_entry, +}; +pub use self::shared::{ + SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, + SharedInstanceInviteInstallPreview, SharedInstanceInviteLink, + SharedInstanceJoinType, SharedInstancePublishPreview, + SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, + SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, + accept_pending_shared_instance_invite, + accept_shared_instance_invite_for_install, + can_active_user_use_shared_instances, create_shared_instance_invite_link, + decline_pending_shared_instance_invite, + get_shared_instance_install_preview, get_shared_instance_publish_preview, + get_shared_instance_update_preview, get_shared_instance_users, + install_shared_instance, invite_shared_instance_users, + publish_shared_instance, remove_shared_instance_users, + unlink_shared_instance, unpublish_shared_instance, update_shared_instance, +}; diff --git a/packages/app-lib/src/api/instance/content_set_diff.rs b/packages/app-lib/src/api/instance/content_set_diff.rs new file mode 100644 index 000000000..57d1e9c17 --- /dev/null +++ b/packages/app-lib/src/api/instance/content_set_diff.rs @@ -0,0 +1,152 @@ +use crate::state::Version; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ContentSetDiffKind { + Added, + Removed, + Updated, +} + +#[derive(Clone, Debug)] +pub(crate) enum ContentSetDiffEntry { + Project { + kind: ContentSetDiffKind, + project_id: String, + current_version_name: Option, + new_version_name: Option, + disabled: bool, + }, + ExternalFile { + kind: ContentSetDiffKind, + file_name: String, + disabled: bool, + }, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ContentSetDiffOptions { + pub removed_disabled_project_ids: HashSet, + pub removed_disabled_external_files: HashSet, + pub common_external_files_are_updated: bool, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ContentSetSnapshot { + pub versions: Vec, + pub external_files: HashSet, +} + +#[derive(Clone, Debug)] +pub(crate) struct ContentSetSnapshotVersion { + pub project_id: String, + pub version_id: String, + pub version_name: String, +} + +impl From for ContentSetSnapshotVersion { + fn from(version: Version) -> Self { + Self { + project_id: version.project_id, + version_id: version.id, + version_name: version.version_number, + } + } +} + +pub(crate) fn diff_content_sets( + current: &ContentSetSnapshot, + latest: &ContentSetSnapshot, + options: &ContentSetDiffOptions, +) -> Vec { + let current_versions = versions_by_project(current); + let latest_versions = versions_by_project(latest); + let project_ids = current_versions + .keys() + .chain(latest_versions.keys()) + .copied() + .collect::>(); + + let mut diffs = Vec::new(); + for project_id in project_ids { + let current = current_versions.get(project_id); + let latest = latest_versions.get(project_id); + + match (current, latest) { + (None, Some(latest)) => { + diffs.push(ContentSetDiffEntry::Project { + kind: ContentSetDiffKind::Added, + project_id: project_id.to_string(), + current_version_name: None, + new_version_name: Some(latest.version_name.clone()), + disabled: false, + }); + } + (Some(current), None) => { + diffs.push(ContentSetDiffEntry::Project { + kind: ContentSetDiffKind::Removed, + project_id: project_id.to_string(), + current_version_name: Some(current.version_name.clone()), + new_version_name: None, + disabled: options + .removed_disabled_project_ids + .contains(project_id), + }); + } + (Some(current), Some(latest)) + if current.version_id != latest.version_id => + { + diffs.push(ContentSetDiffEntry::Project { + kind: ContentSetDiffKind::Updated, + project_id: project_id.to_string(), + current_version_name: Some(current.version_name.clone()), + new_version_name: Some(latest.version_name.clone()), + disabled: false, + }); + } + _ => {} + } + } + + for file_name in latest.external_files.difference(¤t.external_files) { + diffs.push(ContentSetDiffEntry::ExternalFile { + kind: ContentSetDiffKind::Added, + file_name: file_name.clone(), + disabled: false, + }); + } + + if options.common_external_files_are_updated { + for file_name in + latest.external_files.intersection(¤t.external_files) + { + diffs.push(ContentSetDiffEntry::ExternalFile { + kind: ContentSetDiffKind::Updated, + file_name: file_name.clone(), + disabled: false, + }); + } + } + + for file_name in current.external_files.difference(&latest.external_files) { + diffs.push(ContentSetDiffEntry::ExternalFile { + kind: ContentSetDiffKind::Removed, + file_name: file_name.clone(), + disabled: options + .removed_disabled_external_files + .contains(file_name), + }); + } + + diffs +} + +fn versions_by_project( + snapshot: &ContentSetSnapshot, +) -> HashMap<&str, &ContentSetSnapshotVersion> { + snapshot + .versions + .iter() + .map(|version| (version.project_id.as_str(), version)) + .collect() +} diff --git a/packages/app-lib/src/api/instance/lifecycle.rs b/packages/app-lib/src/api/instance/lifecycle.rs index 0139b6b5f..94b37e1f5 100644 --- a/packages/app-lib/src/api/instance/lifecycle.rs +++ b/packages/app-lib/src/api/instance/lifecycle.rs @@ -101,12 +101,27 @@ pub async fn edit_icon( crate::state::edit_instance( instance_id, EditInstance { - icon_path: Some(icon_path), + icon_path: Some(icon_path.clone()), ..EditInstance::default() }, &state.pool, ) .await?; + + if let Err(error) = super::shared::sync_shared_instance_icon( + instance_id, + icon_path.as_deref(), + &state, + ) + .await + { + tracing::warn!( + instance_id, + error = %error, + "Failed to sync shared instance icon" + ); + } + emit_instance(&instance.id, InstancePayloadType::Edited).await?; Ok(()) diff --git a/packages/app-lib/src/api/instance/projects.rs b/packages/app-lib/src/api/instance/projects.rs index 18c3c98e8..d8dc5f0ee 100644 --- a/packages/app-lib/src/api/instance/projects.rs +++ b/packages/app-lib/src/api/instance/projects.rs @@ -1,7 +1,9 @@ use crate::event::emit::{emit_instance, emit_loading, init_loading}; use crate::event::{InstancePayloadType, LoadingBarType}; use crate::state::instances::adapters::sqlite::instance_rows; -use crate::state::{CacheBehaviour, CachedEntry, ProjectType, State}; +use crate::state::{ + CacheBehaviour, CachedEntry, ContentSourceKind, ProjectType, State, +}; use crate::util::fetch; use modrinth_content_management::{ ContentType, ResolutionPreferences, ResolveContentPlan, @@ -23,6 +25,7 @@ pub async fn update_all_projects( instance_id: &str, ) -> crate::Result> { let state = State::get().await?; + ensure_instance_content_unlocked(instance_id, &state).await?; let instance = get_instance_display_info(instance_id, &state).await?; let loading_bar = init_loading( LoadingBarType::InstanceUpdate { @@ -51,13 +54,18 @@ pub async fn update_project( skip_send_event: Option, ) -> crate::Result { let state = State::get().await?; + ensure_shared_instance_can_modify_project( + instance_id, + project_path, + &state, + ) + .await?; let path = crate::state::instances::commands::update_project( instance_id, project_path, &state, ) .await?; - if !skip_send_event.unwrap_or(false) { emit_instance(instance_id, InstancePayloadType::Edited).await?; } @@ -73,6 +81,7 @@ pub async fn add_project_from_version( dependent_on_version_id: Option, ) -> crate::Result { let state = State::get().await?; + ensure_instance_content_unlocked(instance_id, &state).await?; let project_path = crate::state::instances::commands::add_project_from_version( instance_id, @@ -97,6 +106,7 @@ pub async fn install_project_with_dependencies( let metadata = super::get::get(instance_id).await?.ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; + ensure_metadata_content_unlocked(&metadata)?; let plan = crate::state::instances::commands::resolve_install_plan( instance_id, crate::state::instances::commands::InstanceInstallProjectRequest { @@ -181,6 +191,12 @@ pub async fn switch_project_version_with_dependencies( version_id: &str, ) -> crate::Result { let state = State::get().await?; + ensure_shared_instance_can_modify_project( + instance_id, + project_path, + &state, + ) + .await?; let metadata = super::get::get(instance_id).await?.ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; @@ -204,13 +220,18 @@ pub async fn add_project_from_path( project_type: Option, ) -> crate::Result { let state = State::get().await?; - crate::state::instances::commands::add_project_from_path( - instance_id, - path, - project_type, - &state, - ) - .await + ensure_instance_content_unlocked(instance_id, &state).await?; + let project_path = + crate::state::instances::commands::add_project_from_path( + instance_id, + path, + project_type, + &state, + ) + .await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(project_path) } #[tracing::instrument] @@ -235,6 +256,8 @@ pub async fn toggle_disable_project( desired_enabled: Option, ) -> crate::Result { let state = State::get().await?; + ensure_shared_instance_can_modify_project(instance_id, project, &state) + .await?; let res = crate::state::instances::commands::toggle_disable_project( instance_id, project, @@ -253,6 +276,8 @@ pub async fn remove_project( project: &str, ) -> crate::Result<()> { let state = State::get().await?; + ensure_shared_instance_can_modify_project(instance_id, project, &state) + .await?; crate::state::instances::commands::remove_project( instance_id, project, @@ -264,6 +289,45 @@ pub async fn remove_project( Ok(()) } +async fn ensure_shared_instance_can_modify_project( + instance_id: &str, + project_path: &str, + state: &State, +) -> crate::Result<()> { + let metadata = crate::state::instances::commands::get_instance_metadata( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + ensure_metadata_content_unlocked(&metadata)?; + if !metadata + .shared_instance + .is_some_and(|attachment| attachment.role.is_member()) + { + return Ok(()); + } + + let source_kind = + crate::state::instances::commands::content_source_kind_for_project_path( + instance_id, + project_path, + state, + ) + .await?; + if source_kind.is_some_and(ContentSourceKind::is_shared_instance_managed) { + return Err(crate::ErrorKind::InputError( + "Shared instance managed content cannot be changed directly." + .to_string(), + ) + .into()); + } + + Ok(()) +} + #[tracing::instrument] pub async fn update_managed_modrinth_version( instance_id: &str, @@ -278,6 +342,7 @@ pub async fn update_managed_modrinth_version( .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; + ensure_metadata_content_unlocked(&metadata)?; let post_install_edit = match &metadata.link { crate::state::InstanceLink::ServerProjectModpack { @@ -335,6 +400,7 @@ pub async fn repair_managed_modrinth( .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; + ensure_metadata_content_unlocked(&metadata)?; let post_install_edit = match &metadata.link { crate::state::InstanceLink::ServerProjectModpack { .. } => { @@ -381,6 +447,33 @@ fn unmanaged_pack_error(instance_id: &str) -> crate::ErrorKind { )) } +async fn ensure_instance_content_unlocked( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + if instance_rows::is_instance_quarantined(instance_id, &state.pool).await? { + return Err(quarantined_content_error().into()); + } + + Ok(()) +} + +fn ensure_metadata_content_unlocked( + metadata: &crate::state::InstanceMetadata, +) -> crate::Result<()> { + if metadata.quarantined { + return Err(quarantined_content_error().into()); + } + + Ok(()) +} + +fn quarantined_content_error() -> crate::ErrorKind { + crate::ErrorKind::InputError( + "Content in quarantined instances cannot be changed.".to_string(), + ) +} + async fn get_instance_display_info( instance_id: &str, state: &State, diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs index e4d1706de..e5615dded 100644 --- a/packages/app-lib/src/api/instance/run.rs +++ b/packages/app-lib/src/api/instance/run.rs @@ -24,6 +24,23 @@ pub async fn run( quick_play_type: QuickPlayType, ) -> crate::Result { let state = State::get().await?; + if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined( + instance_id, + &state.pool, + ) + .await? + { + return Err(crate::ErrorKind::InputError( + "This instance has been quarantined".to_string(), + ) + .into()); + } + super::shared::check_shared_instance_availability_before_launch( + instance_id, + &state, + ) + .await?; + let default_account = Credentials::get_default_credential(&state.pool) .await? .ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?; @@ -50,6 +67,17 @@ async fn run_credentials( "Tried to run a nonexistent instance {instance_id}!" )) })?; + if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined( + instance_id, + &state.pool, + ) + .await? + { + return Err(crate::ErrorKind::InputError( + "This instance has been quarantined".to_string(), + ) + .into()); + } let pre_launch_hooks = context .launch_overrides diff --git a/packages/app-lib/src/api/instance/shared/client.rs b/packages/app-lib/src/api/instance/shared/client.rs new file mode 100644 index 000000000..05e00a46b --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/client.rs @@ -0,0 +1,1014 @@ +use super::install::*; +use super::*; + +#[derive(Clone, Copy, Debug)] +pub(super) enum SharedInstancesRequestAuth { + ModrinthSession, + None, +} + +impl SharedInstancesRequestAuth { + pub(super) fn label(self) -> &'static str { + match self { + Self::ModrinthSession => "modrinth_session", + Self::None => "none", + } + } +} + +impl SharedInstanceUnavailableReason { + pub(super) fn from_status(status: StatusCode) -> Option { + match status { + StatusCode::NOT_FOUND => Some(Self::Deleted), + StatusCode::UNAUTHORIZED => Some(Self::AccessRevoked), + _ => None, + } + } +} + +#[derive(Debug)] +pub(super) enum SharedInstanceRemoteResponse { + Available(T), + Unavailable(SharedInstanceUnavailableReason), +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct RemoteInstanceResponse { + #[serde(default)] + pub(super) quarantine: bool, +} + +#[derive(Clone, Debug)] +pub(super) struct ExternalFileCandidate { + pub(super) file_name: String, + pub(super) file_type: String, + pub(super) source: ExternalFileSource, +} + +#[derive(Clone, Debug)] +pub(super) enum ExternalFileSource { + InstanceFile(String), + ConfigBundle(Vec), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(super) struct ConfigFile { + pub(super) path: String, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct ExternalFileData { + pub(super) file_name: String, + pub(super) file_type: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct CreateInstanceResponse { + #[serde(alias = "instance_id")] + pub(super) id: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct CreateInstanceInviteResponse { + pub(super) id: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct BlacklistStatusResponse { + pub(super) blacklisted: bool, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct InstanceInviteInfoResponse { + pub(super) instance_id: String, + pub(super) instance_name: String, + #[serde(default)] + pub(super) instance_icon: Option, + #[serde(default)] + pub(super) managers: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum InstanceInviteManagerResponse { + User { id: String }, + Server { name: String, icon: Option }, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct InstanceVersionResponse { + pub(super) version: i32, + #[serde(default)] + pub(super) modrinth_ids: Vec, + pub(super) ready: bool, + #[serde(default)] + pub(super) external_files: Vec, + #[serde(default)] + pub(super) modpack_id: Option, + pub(super) game_version: String, + pub(super) loader: ModLoader, + pub(super) loader_version: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct ExternalFileResponse { + pub(super) file_name: String, + pub(super) file_type: String, + pub(super) url: String, + #[serde(default)] + pub(super) file_size: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct RemoteSharedInstanceUsers { + #[serde(default)] + pub(super) users: Vec, + #[serde(default)] + pub(super) tokens: i32, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(super) enum RemoteSharedInstanceUsersResponse { + Current(RemoteSharedInstanceUsers), + Legacy(Vec), +} + +impl RemoteSharedInstanceUsersResponse { + pub(super) fn into_shared_users(self) -> SharedInstanceUsers { + match self { + Self::Current(response) => { + SharedInstanceUsers::from_users(response.users, response.tokens) + } + Self::Legacy(user_ids) => { + SharedInstanceUsers::from_user_ids(user_ids) + } + } + } +} + +pub(super) async fn create_remote_instance( + name: String, + state: &State, +) -> crate::Result { + request_json( + "create_instance", + Method::POST, + "/instances", + Some(json!({ "name": name })), + state, + ) + .await +} + +pub(super) async fn get_user_blacklist_status( + user_id: &str, + state: &State, +) -> crate::Result { + request_json( + "get_user_blacklist_status", + Method::GET, + &format!("/blacklist/{user_id}"), + None, + state, + ) + .await +} + +pub(super) async fn delete_remote_instance( + shared_instance_id: &str, + state: &State, +) -> crate::Result<()> { + let operation = "delete_instance"; + let method = Method::DELETE; + let path = format!("/instances/{shared_instance_id}"); + let response = + send_request(operation, method.clone(), &path, None, state).await?; + + if response.status().is_success() + || matches!(response.status(), StatusCode::NOT_FOUND | StatusCode::GONE) + { + return Ok(()); + } + + shared_instances_request_error(operation, method, &path, response).await +} + +pub(super) async fn update_remote_instance( + shared_instance_id: &str, + name: String, + state: &State, +) -> crate::Result<()> { + let operation = "update_instance"; + let method = Method::PATCH; + let path = format!("/instances/{shared_instance_id}"); + let response = send_request( + operation, + method.clone(), + &path, + Some(json!({ "name": name })), + state, + ) + .await?; + + if response.status().is_success() { + return Ok(()); + } + + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + let request_id = response_request_id(&response); + tracing::warn!( + operation, + method = method.as_str(), + path, + status = StatusCode::METHOD_NOT_ALLOWED.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API does not support remote instance updates; skipping name sync" + ); + return Ok(()); + } + + shared_instances_request_error(operation, method, &path, response).await +} + +pub(super) async fn get_remote_instance_access( + shared_instance_id: &str, + state: &State, +) -> crate::Result> { + let operation = "get_instance"; + let method = Method::GET; + let path = format!("/instances/{shared_instance_id}"); + let response = + send_request(operation, method.clone(), &path, None, state).await?; + + if let Some(reason) = + SharedInstanceUnavailableReason::from_status(response.status()) + { + if reason == SharedInstanceUnavailableReason::AccessRevoked + && !active_modrinth_session_is_valid(state).await? + { + return Err(crate::ErrorKind::NoCredentialsError.into()); + } + + return Ok(SharedInstanceRemoteResponse::Unavailable(reason)); + } + + if !response.status().is_success() { + return shared_instances_request_error( + operation, method, &path, response, + ) + .await; + } + + let instance = decode_json_response::( + operation, method, &path, response, + ) + .await?; + if instance.quarantine { + return Ok(SharedInstanceRemoteResponse::Unavailable( + SharedInstanceUnavailableReason::Quarantined, + )); + } + + Ok(SharedInstanceRemoteResponse::Available(())) +} + +pub(super) async fn update_remote_instance_icon( + shared_instance_id: &str, + icon_path: Option<&str>, + state: &State, +) -> crate::Result<()> { + let path = format!("/instances/{shared_instance_id}/icon"); + let Some(icon_path) = icon_path else { + return request_empty( + "delete_instance_icon", + Method::DELETE, + &path, + None, + state, + ) + .await; + }; + + let bytes = crate::util::io::read(icon_path).await?; + let operation = "upload_instance_icon"; + let method = Method::PUT; + let response = + send_bytes_request(operation, method.clone(), &path, bytes, state) + .await?; + + if response.status().is_success() { + return Ok(()); + } + + shared_instances_request_error(operation, method, &path, response).await +} + +pub(super) async fn get_remote_users( + shared_instance_id: &str, + state: &State, +) -> crate::Result { + let users = request_json::( + "get_instance_users", + Method::GET, + &format!("/instances/{shared_instance_id}/users"), + None, + state, + ) + .await?; + + Ok(users.into_shared_users()) +} + +pub(super) async fn get_latest_remote_version( + shared_instance_id: &str, + state: &State, +) -> crate::Result { + match get_latest_remote_version_optional_unavailable( + shared_instance_id, + state, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => Ok(version), + SharedInstanceRemoteResponse::Unavailable( + reason @ SharedInstanceUnavailableReason::AccessRevoked, + ) => { + if !accept_pending_remote_invite(shared_instance_id, state).await? { + return Err(shared_instance_unavailable_error(reason)); + } + + match get_latest_remote_version_optional_unavailable( + shared_instance_id, + state, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => Ok(version), + SharedInstanceRemoteResponse::Unavailable(reason) => { + Err(shared_instance_unavailable_error(reason)) + } + } + } + SharedInstanceRemoteResponse::Unavailable(reason) => { + Err(shared_instance_unavailable_error(reason)) + } + } +} + +pub(super) async fn get_latest_remote_version_optional_unavailable( + shared_instance_id: &str, + state: &State, +) -> crate::Result> { + get_latest_remote_version_optional_unavailable_with_auth( + shared_instance_id, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await +} + +pub(super) async fn get_latest_remote_version_optional_unavailable_with_auth( + shared_instance_id: &str, + state: &State, + auth: SharedInstancesRequestAuth, +) -> crate::Result> { + request_json_optional_unavailable( + "get_latest_instance_version", + Method::GET, + &format!("/instances/{shared_instance_id}/versions"), + None, + state, + auth, + ) + .await +} + +pub(super) async fn add_remote_users( + shared_instance_id: &str, + user_ids: Vec, + state: &State, +) -> crate::Result<()> { + request_empty( + "add_instance_users", + Method::POST, + &format!("/instances/{shared_instance_id}/users"), + Some(json!({ "user_ids": user_ids })), + state, + ) + .await +} + +pub(super) async fn remove_remote_users( + shared_instance_id: &str, + user_ids: Vec, + state: &State, +) -> crate::Result<()> { + request_empty( + "remove_instance_users", + Method::DELETE, + &format!("/instances/{shared_instance_id}/users"), + Some(json!({ "user_ids": user_ids })), + state, + ) + .await +} + +pub(super) async fn accept_pending_remote_invite( + shared_instance_id: &str, + state: &State, +) -> crate::Result { + let operation = "accept_pending_instance_invite"; + let method = Method::POST; + let path = format!("/instances/{shared_instance_id}/invites/pending"); + let response = + send_request(operation, method.clone(), &path, None, state).await?; + + match response.status() { + StatusCode::OK | StatusCode::NO_CONTENT => Ok(true), + StatusCode::NOT_FOUND => Ok(false), + status if status.is_success() => Ok(true), + _ => { + shared_instances_request_error(operation, method, &path, response) + .await + } + } +} + +pub(super) async fn accept_shared_instance_invite( + shared_instance_id: &str, + invite_id: &str, + state: &State, +) -> crate::Result<()> { + let path = format!("/instances/{shared_instance_id}/invites/{invite_id}"); + let log_path = "/instances/:instance_id/invites/:invite_id"; + let operation = "accept_instance_invite"; + let method = Method::POST; + + let response = send_request_with_auth_and_log_path( + operation, + method.clone(), + &path, + log_path, + None, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await?; + if response.status().is_success() { + return Ok(()); + } + + let status = response.status(); + let request_id = response_request_id(&response); + let body = response + .text() + .await + .map_err(reqwest::Error::without_url) + .unwrap_or_default(); + if status == StatusCode::BAD_REQUEST && body.contains("already has access") + { + return Ok(()); + } + + tracing::warn!( + operation, + method = method.as_str(), + path = log_path, + status = status.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API request failed" + ); + Err(crate::ErrorKind::OtherError(format!( + "Shared instances API request {operation} {method} {log_path} failed with status {status}" + )) + .into()) +} + +pub(super) async fn delete_remote_invite( + shared_instance_id: &str, + invite_id: &str, + state: &State, +) -> crate::Result<()> { + let path = format!("/instances/{shared_instance_id}/invites/{invite_id}"); + request_empty_with_log_path( + "delete_instance_invite", + Method::DELETE, + &path, + "/instances/:instance_id/invites/:invite_id", + None, + state, + ) + .await +} + +pub(super) async fn get_shared_instance_invite_info( + invite_id: &str, + state: &State, +) -> crate::Result { + let operation = "get_instance_invite"; + let method = Method::GET; + let path = format!("/invites/{invite_id}"); + let log_path = "/invites/:invite_id"; + let response = send_request_with_auth_and_log_path( + operation, + method.clone(), + &path, + log_path, + None, + state, + SharedInstancesRequestAuth::None, + ) + .await?; + if !response.status().is_success() { + return shared_instances_request_error( + operation, method, log_path, response, + ) + .await; + } + + decode_json_response_with_log_path( + operation, method, log_path, response, true, + ) + .await +} + +pub(super) async fn decline_pending_remote_invite( + shared_instance_id: &str, + state: &State, +) -> crate::Result<()> { + request_empty( + "decline_pending_instance_invite", + Method::DELETE, + &format!("/instances/{shared_instance_id}/invites/pending"), + None, + state, + ) + .await +} + +pub(super) async fn request_json( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, +) -> crate::Result +where + T: DeserializeOwned, +{ + let response = + request(operation, method.clone(), path, body, state).await?; + decode_json_response(operation, method, path, response).await +} + +pub(super) async fn request_json_optional_unavailable( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, + auth: SharedInstancesRequestAuth, +) -> crate::Result> +where + T: DeserializeOwned, +{ + let response = send_request_with_auth( + operation, + method.clone(), + path, + body, + state, + auth, + ) + .await?; + if let Some(reason) = + SharedInstanceUnavailableReason::from_status(response.status()) + { + if reason == SharedInstanceUnavailableReason::AccessRevoked + && matches!(auth, SharedInstancesRequestAuth::ModrinthSession) + && !active_modrinth_session_is_valid(state).await? + { + tracing::warn!( + operation, + method = method.as_str(), + path, + status = response.status().as_u16(), + "Shared instances API returned unauthorized while Modrinth auth is unavailable" + ); + return Err(crate::ErrorKind::NoCredentialsError.into()); + } + + tracing::warn!( + operation, + method = method.as_str(), + path, + status = response.status().as_u16(), + "Shared instances API resource is unavailable" + ); + return Ok(SharedInstanceRemoteResponse::Unavailable(reason)); + } + + if !response.status().is_success() { + return shared_instances_request_error( + operation, method, path, response, + ) + .await; + } + + decode_json_response(operation, method, path, response) + .await + .map(SharedInstanceRemoteResponse::Available) +} + +pub(super) async fn active_modrinth_session_is_valid( + state: &State, +) -> crate::Result { + let Some(credentials) = + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + else { + return Ok(false); + }; + + let _permit = state.api_semaphore.0.acquire().await?; + let response = INSECURE_REQWEST_CLIENT + .get(concat!(env!("MODRINTH_API_URL"), "user")) + .header("Authorization", &credentials.session) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + ModrinthCredentials::remove(&credentials.user_id, &state.pool).await?; + return Ok(false); + } + + if response.status().is_success() { + return Ok(true); + } + + let status = response.status(); + let request_id = response_request_id(&response); + tracing::warn!( + operation = "validate_modrinth_session", + method = Method::GET.as_str(), + path = "/user", + status = status.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Modrinth auth validation request failed" + ); + Err(crate::ErrorKind::OtherError(format!( + "Modrinth auth validation failed with status {status}" + )) + .into()) +} + +pub(super) async fn decode_json_response( + operation: &'static str, + method: Method, + path: &str, + response: reqwest::Response, +) -> crate::Result +where + T: DeserializeOwned, +{ + decode_json_response_with_log_path(operation, method, path, response, false) + .await +} + +async fn decode_json_response_with_log_path( + operation: &'static str, + method: Method, + log_path: &str, + response: reqwest::Response, + strip_response_url: bool, +) -> crate::Result +where + T: DeserializeOwned, +{ + let status = response.status(); + let request_id = response_request_id(&response); + let body = match response.text().await { + Ok(body) => body, + Err(error) if strip_response_url => { + return Err(error.without_url().into()); + } + Err(error) => return Err(error.into()), + }; + serde_json::from_str::(&body).map_err(|error| { + tracing::warn!( + operation, + method = method.as_str(), + path = log_path, + status = status.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + error_category = ?error.classify(), + error_line = error.line(), + error_column = error.column(), + "Shared instances API returned an invalid JSON response" + ); + crate::ErrorKind::OtherError(format!( + "Shared instances API request {operation} {method} {log_path} returned invalid JSON with status {status}" + )) + .into() + }) +} + +pub(super) async fn request_empty( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, +) -> crate::Result<()> { + request(operation, method, path, body, state).await?; + Ok(()) +} + +async fn request_empty_with_log_path( + operation: &'static str, + method: Method, + path: &str, + log_path: &str, + body: Option, + state: &State, +) -> crate::Result<()> { + let response = send_request_with_auth_and_log_path( + operation, + method.clone(), + path, + log_path, + body, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await?; + if response.status().is_success() { + return Ok(()); + } + + shared_instances_request_error(operation, method, log_path, response).await +} + +pub(super) async fn request( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, +) -> crate::Result { + let response = + send_request(operation, method.clone(), path, body, state).await?; + if response.status().is_success() { + return Ok(response); + } + + shared_instances_request_error(operation, method, path, response).await +} + +pub(super) async fn send_request( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, +) -> crate::Result { + send_request_with_auth( + operation, + method, + path, + body, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await +} + +pub(super) async fn send_bytes_request( + operation: &'static str, + method: Method, + path: &str, + body: Vec, + state: &State, +) -> crate::Result { + let base_url = service_base_url(); + let url = service_url(base_url, path); + send_bytes_request_to_url(operation, method, path, &url, body, state).await +} + +pub(super) async fn send_bytes_request_to_url( + operation: &'static str, + method: Method, + path: &str, + url: &str, + body: Vec, + state: &State, +) -> crate::Result { + let service_origin = url::Url::parse(service_base_url()) + .map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Invalid shared instances API base URL: {error}" + )) + })? + .origin(); + let upload_origin = url::Url::parse(url) + .map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Invalid shared instances upload URL: {error}" + )) + })? + .origin(); + if service_origin != upload_origin { + return Err(crate::ErrorKind::OtherError( + "Shared instances upload URL has an unexpected origin".to_string(), + ) + .into()); + } + + let credentials = + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + .ok_or(crate::ErrorKind::NoCredentialsError)?; + let _permit = state.api_semaphore.0.acquire().await?; + + tracing::debug!( + operation, + method = method.as_str(), + path, + url = %url, + user_id = credentials.user_id.as_str(), + auth = SharedInstancesRequestAuth::ModrinthSession.label(), + has_body = true, + "Sending shared instances API request" + ); + + let response = shared_instances_client(url) + .request(method.clone(), url) + .bearer_auth(credentials.session) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(body) + .send() + .await?; + + if response.status().is_success() { + let request_id = response_request_id(&response); + tracing::debug!( + operation, + method = method.as_str(), + path, + url = %url, + status = response.status().as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API request succeeded" + ); + } + + Ok(response) +} + +pub(super) async fn send_request_with_auth( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, + auth: SharedInstancesRequestAuth, +) -> crate::Result { + send_request_with_auth_and_log_path( + operation, method, path, path, body, state, auth, + ) + .await +} + +async fn send_request_with_auth_and_log_path( + operation: &'static str, + method: Method, + path: &str, + log_path: &str, + body: Option, + state: &State, + auth: SharedInstancesRequestAuth, +) -> crate::Result { + let modrinth_credentials = + if matches!(auth, SharedInstancesRequestAuth::ModrinthSession) { + Some( + ModrinthCredentials::get_and_refresh( + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or(crate::ErrorKind::NoCredentialsError)?, + ) + } else { + None + }; + let _permit = state.api_semaphore.0.acquire().await?; + let base_url = service_base_url(); + let url = service_url(base_url, path); + let log_url = service_url(base_url, log_path); + let mut request = + shared_instances_client(base_url).request(method.clone(), &url); + let mut user_id = None; + + match auth { + SharedInstancesRequestAuth::ModrinthSession => { + let credentials = modrinth_credentials + .expect("Modrinth session credentials were loaded"); + user_id = Some(credentials.user_id); + request = request.bearer_auth(credentials.session); + } + SharedInstancesRequestAuth::None => {} + } + + tracing::debug!( + operation, + method = method.as_str(), + path = log_path, + url = %log_url, + user_id = user_id.as_deref(), + auth = auth.label(), + has_body = body.is_some(), + "Sending shared instances API request" + ); + + if let Some(body) = body { + request = request.json(&body); + } + + let response = match request.send().await { + Ok(response) => response, + Err(error) if path != log_path => { + return Err(error.without_url().into()); + } + Err(error) => return Err(error.into()), + }; + if response.status().is_success() { + let request_id = response_request_id(&response); + tracing::debug!( + operation, + method = method.as_str(), + path = log_path, + url = %log_url, + status = response.status().as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API request succeeded" + ); + } + Ok(response) +} + +pub(super) async fn shared_instances_request_error( + operation: &'static str, + method: Method, + path: &str, + response: reqwest::Response, +) -> crate::Result { + let status = response.status(); + let request_id = response_request_id(&response); + tracing::warn!( + operation, + method = method.as_str(), + path, + status = status.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API request failed" + ); + Err(crate::ErrorKind::OtherError(format!( + "Shared instances API request {operation} {method} {path} failed with status {status}" + )) + .into()) +} + +pub(super) fn response_request_id( + response: &reqwest::Response, +) -> Option { + let request_id = response.headers().get("x-request-id")?.to_str().ok()?; + if request_id.is_empty() + || request_id.len() > 128 + || !request_id.chars().all(|character| { + character.is_ascii_alphanumeric() || "-_.:".contains(character) + }) + { + return None; + } + + Some(request_id.to_string()) +} + +pub(super) fn service_url(base_url: &str, path: &str) -> String { + format!("{base_url}/v1{path}") +} + +pub(super) fn service_base_url() -> &'static str { + env!("SHARED_INSTANCES_API_BASE_URL").trim_end_matches('/') +} + +pub(super) fn shared_instances_client( + base_url: &str, +) -> &'static reqwest::Client { + if base_url.starts_with("https://") { + &REQWEST_CLIENT + } else { + &INSECURE_REQWEST_CLIENT + } +} diff --git a/packages/app-lib/src/api/instance/shared/diff.rs b/packages/app-lib/src/api/instance/shared/diff.rs new file mode 100644 index 000000000..072c9cb66 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/diff.rs @@ -0,0 +1,432 @@ +use super::client::*; +use super::publish::*; +use super::types::*; +use super::*; + +pub(super) async fn shared_instance_update_diffs( + metadata: &crate::state::InstanceMetadata, + version: &InstanceVersionResponse, + state: &State, +) -> crate::Result> { + let remote_modpack_id = + version.modpack_id.as_deref().filter(|id| !id.is_empty()); + let current_modpack_id = shared_modpack_id(&metadata.link); + let modpack_unlinked = + current_modpack_id.is_some() && remote_modpack_id.is_none(); + let (current_version_ids, current_external_files) = + current_shared_content(metadata, modpack_unlinked, state).await?; + let (latest_version_ids, latest_external_files) = + remote_shared_content(version); + let removed_disabled_project_ids = HashSet::new(); + let removed_disabled_external_files = HashSet::new(); + let mut diffs = shared_content_diffs( + ¤t_version_ids, + ¤t_external_files, + &latest_version_ids, + &latest_external_files, + &removed_disabled_project_ids, + &removed_disabled_external_files, + true, + state, + ) + .await?; + let mut configuration_diffs = shared_instance_configuration_diffs( + current_modpack_id.as_deref(), + remote_modpack_id, + &metadata.applied_content_set.game_version, + &version.game_version, + metadata.applied_content_set.loader, + version.loader, + metadata.applied_content_set.loader_version.as_deref(), + Some(version.loader_version.as_str()), + state, + ) + .await?; + configuration_diffs.append(&mut diffs); + + Ok(configuration_diffs) +} + +pub(super) async fn shared_instance_publish_diffs( + metadata: &crate::state::InstanceMetadata, + version: &InstanceVersionResponse, + snapshot: &CurrentPublishSnapshot, + state: &State, +) -> crate::Result> { + let remote_modpack_id = + version.modpack_id.as_deref().filter(|id| !id.is_empty()); + let current_modpack_id = shared_modpack_id(&metadata.link); + let modpack_unlinked = + remote_modpack_id.is_some() && current_modpack_id.is_none(); + let disabled_versions = async { + if snapshot.disabled_version_ids.is_empty() { + Ok(HashMap::new()) + } else { + shared_versions_by_project(&snapshot.disabled_version_ids, state) + .await + } + }; + let ((latest_version_ids, latest_external_files), disabled_versions) = tokio::try_join!( + remote_publish_content(version, modpack_unlinked, state), + disabled_versions, + )?; + let current_external_files = snapshot + .external_files + .iter() + .map(|file| file.file_name.clone()) + .collect::>(); + let current_version_ids = snapshot + .version_ids + .iter() + .filter(|id| current_modpack_id.as_deref() != Some(id.as_str())) + .cloned() + .collect::>(); + let mut removed_disabled_project_ids = + snapshot.disabled_project_ids.clone(); + removed_disabled_project_ids.extend(disabled_versions.into_keys()); + let mut diffs = shared_content_diffs( + &latest_version_ids, + &latest_external_files, + ¤t_version_ids, + ¤t_external_files, + &removed_disabled_project_ids, + &snapshot.disabled_external_files, + false, + state, + ) + .await?; + let mut configuration_diffs = shared_instance_configuration_diffs( + remote_modpack_id, + current_modpack_id.as_deref(), + &version.game_version, + &metadata.applied_content_set.game_version, + version.loader, + metadata.applied_content_set.loader, + Some(version.loader_version.as_str()), + metadata.applied_content_set.loader_version.as_deref(), + state, + ) + .await?; + configuration_diffs.append(&mut diffs); + + Ok(configuration_diffs) +} + +pub(super) async fn shared_instance_configuration_diffs( + current_modpack_id: Option<&str>, + new_modpack_id: Option<&str>, + current_game_version: &str, + new_game_version: &str, + current_loader: ModLoader, + new_loader: ModLoader, + current_loader_version: Option<&str>, + new_loader_version: Option<&str>, + state: &State, +) -> crate::Result> { + let mut diffs = Vec::new(); + + if current_modpack_id != new_modpack_id { + match (current_modpack_id, new_modpack_id) { + (None, Some(_)) => diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::ModpackLinked, + None, + shared_modpack_version_label(new_modpack_id, state).await, + )), + (Some(_), None) => diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::ModpackUnlinked, + shared_modpack_version_label(current_modpack_id, state).await, + None, + )), + (Some(current_modpack_id), Some(new_modpack_id)) => { + let current = + shared_modpack_version_details(current_modpack_id, state) + .await; + let new = + shared_modpack_version_details(new_modpack_id, state).await; + let project_name = new + .as_ref() + .and_then(|details| details.project_name.clone()) + .or_else(|| { + current + .as_ref() + .and_then(|details| details.project_name.clone()) + }); + + diffs.push(SharedInstanceUpdateDiff { + type_: SharedInstanceUpdateDiffType::ModpackUpdated, + project_id: None, + project_name, + file_name: None, + current_version_name: current + .map(|details| details.version_name), + new_version_name: new.map(|details| details.version_name), + config_file_count: None, + disabled: false, + }); + } + (None, None) => unreachable!(), + } + } + + if current_game_version != new_game_version { + diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::GameVersionUpdated, + Some(current_game_version.to_string()), + Some(new_game_version.to_string()), + )); + } + + let current_loader_version = + normalized_loader_version(current_loader_version); + let new_loader_version = normalized_loader_version(new_loader_version); + if current_loader != new_loader + || current_loader_version != new_loader_version + { + diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::LoaderUpdated, + Some(shared_loader_label(current_loader, current_loader_version)), + Some(shared_loader_label(new_loader, new_loader_version)), + )); + } + + Ok(diffs) +} + +pub(super) fn configuration_diff( + type_: SharedInstanceUpdateDiffType, + current_version_name: Option, + new_version_name: Option, +) -> SharedInstanceUpdateDiff { + SharedInstanceUpdateDiff { + type_, + project_id: None, + project_name: None, + file_name: None, + current_version_name, + new_version_name, + config_file_count: None, + disabled: false, + } +} + +pub(super) async fn shared_modpack_version_label( + version_id: Option<&str>, + state: &State, +) -> Option { + let version_id = version_id?; + let details = shared_modpack_version_details(version_id, state).await?; + + Some(match details.project_name { + Some(project_name) => { + format!("{project_name} {}", details.version_name) + } + None => details.version_name, + }) +} + +struct SharedModpackVersionDetails { + project_name: Option, + version_name: String, +} + +async fn shared_modpack_version_details( + version_id: &str, + state: &State, +) -> Option { + let Some(version) = CachedEntry::get_version( + version_id, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await + .ok() + .flatten() else { + return Some(SharedModpackVersionDetails { + project_name: None, + version_name: version_id.to_string(), + }); + }; + let project = CachedEntry::get_project( + &version.project_id, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await + .ok() + .flatten(); + + Some(SharedModpackVersionDetails { + project_name: Some( + project.map(|project| project.title).unwrap_or(version.name), + ), + version_name: version.version_number, + }) +} + +pub(super) fn normalized_loader_version( + loader_version: Option<&str>, +) -> Option<&str> { + loader_version.filter(|version| !version.is_empty()) +} + +pub(super) fn shared_loader_label( + loader: ModLoader, + loader_version: Option<&str>, +) -> String { + let loader_name = match loader { + ModLoader::Vanilla => "Vanilla", + ModLoader::Forge => "Forge", + ModLoader::Fabric => "Fabric", + ModLoader::Quilt => "Quilt", + ModLoader::NeoForge => "NeoForge", + }; + + match loader_version { + Some(version) => format!("{loader_name} {version}"), + None => loader_name.to_string(), + } +} + +pub(super) async fn shared_content_diffs( + current_version_ids: &[String], + current_external_files: &HashSet, + latest_version_ids: &[String], + latest_external_files: &HashSet, + removed_disabled_project_ids: &HashSet, + removed_disabled_external_files: &HashSet, + common_external_files_are_updated: bool, + state: &State, +) -> crate::Result> { + let current = shared_content_snapshot( + current_version_ids, + current_external_files, + state, + ) + .await?; + let latest = shared_content_snapshot( + latest_version_ids, + latest_external_files, + state, + ) + .await?; + let options = ContentSetDiffOptions { + removed_disabled_project_ids: removed_disabled_project_ids.clone(), + removed_disabled_external_files: removed_disabled_external_files + .clone(), + common_external_files_are_updated, + }; + let content_diffs = diff_content_sets(¤t, &latest, &options); + let project_ids = content_diffs + .iter() + .filter_map(|diff| match diff { + ContentSetDiffEntry::Project { project_id, .. } => { + Some(project_id.clone()) + } + ContentSetDiffEntry::ExternalFile { .. } => None, + }) + .collect::>(); + let project_names = shared_project_names(&project_ids, state).await?; + + let mut diffs = Vec::new(); + for diff in content_diffs { + match diff { + ContentSetDiffEntry::Project { + kind, + project_id, + current_version_name, + new_version_name, + disabled, + } => { + let project_name = Some( + project_names + .get(&project_id) + .cloned() + .unwrap_or_else(|| project_id.clone()), + ); + diffs.push(SharedInstanceUpdateDiff { + type_: shared_update_diff_type(kind), + project_id: Some(project_id), + project_name, + file_name: None, + current_version_name, + new_version_name, + config_file_count: None, + disabled, + }); + } + ContentSetDiffEntry::ExternalFile { + kind, + file_name, + disabled, + } => { + diffs.push(SharedInstanceUpdateDiff { + type_: shared_update_diff_type(kind), + project_id: None, + project_name: None, + file_name: Some(file_name), + current_version_name: None, + new_version_name: None, + config_file_count: None, + disabled, + }); + } + } + } + + diffs.sort_by(|a, b| { + a.project_name + .as_deref() + .or(a.file_name.as_deref()) + .cmp(&b.project_name.as_deref().or(b.file_name.as_deref())) + }); + Ok(diffs) +} + +pub(super) fn shared_update_diff_type( + kind: ContentSetDiffKind, +) -> SharedInstanceUpdateDiffType { + match kind { + ContentSetDiffKind::Added => SharedInstanceUpdateDiffType::Added, + ContentSetDiffKind::Removed => SharedInstanceUpdateDiffType::Removed, + ContentSetDiffKind::Updated => SharedInstanceUpdateDiffType::Updated, + } +} + +pub(super) async fn shared_content_snapshot( + version_ids: &[String], + external_files: &HashSet, + state: &State, +) -> crate::Result { + let versions = shared_versions_by_project(version_ids, state) + .await? + .into_values() + .map(ContentSetSnapshotVersion::from) + .collect(); + + Ok(ContentSetSnapshot { + versions, + external_files: external_files.clone(), + }) +} + +pub(super) fn remote_shared_content( + version: &InstanceVersionResponse, +) -> (Vec, HashSet) { + let mut version_ids = version.modrinth_ids.clone(); + if let Some(modpack_id) = version.modpack_id.as_deref() { + version_ids.retain(|id| id != modpack_id); + } + dedupe_strings(&mut version_ids); + + ( + version_ids, + version + .external_files + .iter() + .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .map(|file| file.file_name.clone()) + .collect(), + ) +} diff --git a/packages/app-lib/src/api/instance/shared/install.rs b/packages/app-lib/src/api/instance/shared/install.rs new file mode 100644 index 000000000..85a82a809 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/install.rs @@ -0,0 +1,574 @@ +use super::client::*; +use super::diff::*; +use super::publish::*; +use super::types::*; +use super::*; + +#[tracing::instrument] +pub async fn install_shared_instance( + shared_instance_id: &str, + name: String, + manager_id: Option, + server_manager_name: Option, + server_manager_icon_url: Option, + instance_icon_url: Option, +) -> crate::Result { + let state = State::get().await?; + let version = get_latest_remote_version(shared_instance_id, &state).await?; + let data = shared_instance_install_data( + shared_instance_id, + manager_id, + server_manager_name, + server_manager_icon_url, + instance_icon_url, + name, + version, + &state, + ) + .await?; + + crate::install::create_shared_instance(data).await +} + +pub(super) fn shared_instance_invite_install_name( + shared_instance_id: &str, + name: String, +) -> String { + let name = name.trim(); + if name.is_empty() || name == "Shared instance" { + return format!("Shared instance {shared_instance_id}"); + } + + name.to_string() +} + +#[tracing::instrument] +pub async fn get_shared_instance_install_preview( + shared_instance_id: &str, + name: String, +) -> crate::Result { + let state = State::get().await?; + let version = get_latest_remote_version(shared_instance_id, &state).await?; + shared_instance_install_preview_from_version( + shared_instance_id.to_string(), + name, + version, + &state, + ) + .await +} + +#[tracing::instrument(skip(invite_id))] +pub async fn accept_shared_instance_invite_for_install( + invite_id: &str, +) -> crate::Result { + let state = State::get().await?; + let invite = get_shared_instance_invite_info(invite_id, &state).await?; + let shared_instance_id = invite.instance_id; + let instance_icon_url = invite.instance_icon; + let (manager_id, server_manager_name, server_manager_icon_url) = + shared_instance_invite_manager(invite.managers); + let name = shared_instance_invite_install_name( + &shared_instance_id, + invite.instance_name, + ); + accept_shared_instance_invite(&shared_instance_id, invite_id, &state) + .await?; + let version = + get_latest_remote_version(&shared_instance_id, &state).await?; + let mut preview = shared_instance_install_preview_from_version( + shared_instance_id.clone(), + name, + version, + &state, + ) + .await?; + if instance_icon_url.is_some() { + preview.icon_url.clone_from(&instance_icon_url); + } + + Ok(SharedInstanceInviteInstallPreview { + shared_instance_id, + manager_id, + server_manager_name, + server_manager_icon_url, + instance_icon_url, + preview, + }) +} + +pub(super) fn shared_instance_invite_manager( + managers: Vec, +) -> (Option, Option, Option) { + match managers.into_iter().next() { + Some(InstanceInviteManagerResponse::User { id }) => { + (Some(id), None, None) + } + Some(InstanceInviteManagerResponse::Server { name, icon }) => { + (None, Some(name), icon) + } + None => (None, None, None), + } +} + +pub(super) async fn shared_instance_install_preview_from_version( + shared_instance_id: String, + name: String, + version: InstanceVersionResponse, + state: &State, +) -> crate::Result { + let name = match name.trim() { + "" => "Shared instance".to_string(), + name => name.to_string(), + }; + let mut content_version_ids = version.modrinth_ids.clone(); + let mut seen_content_version_ids = HashSet::new(); + content_version_ids + .retain(|id| seen_content_version_ids.insert(id.clone())); + let modpack = shared_instance_install_modpack(&version, state).await?; + let modpack_dependency_count = modpack + .as_ref() + .map(|modpack| modpack.dependency_count) + .unwrap_or_default(); + let modpack_version_id = + modpack.as_ref().map(|modpack| modpack.version_id.clone()); + if let Some(modpack_version_id) = modpack_version_id.as_deref() { + content_version_ids.retain(|id| id != modpack_version_id); + } + let icon_url = modpack + .as_ref() + .and_then(|modpack| modpack.icon_url.clone()); + + let external_files = version + .external_files + .iter() + .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .map(|file| SharedInstanceExternalFilePreview { + file_name: file.file_name.clone(), + file_type: file.file_type.clone(), + }) + .collect::>(); + let external_file_count = external_files.len(); + + Ok(SharedInstanceInstallPreview { + shared_instance_id, + version: version.version, + name, + icon_url, + game_version: version.game_version, + loader: version.loader, + mod_count: modpack_dependency_count + + content_version_ids.len() + + external_file_count, + external_file_count, + modpack_version_id, + content_version_ids, + external_files, + }) +} + +#[tracing::instrument] +pub async fn get_shared_instance_update_preview( + instance_id: &str, +) -> crate::Result> { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance.clone() else { + return Ok(None); + }; + if !attachment.role.is_member() { + if let SharedInstanceRemoteResponse::Unavailable(reason) = + get_remote_instance_access(&attachment.id, &state).await? + { + handle_unavailable_shared_instance_if_current_user( + instance_id, + &attachment, + reason, + &state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + + return Ok(None); + } + + let version = + get_latest_remote_member_version(instance_id, &attachment, &state) + .await?; + if !version.ready { + return Ok(Some(SharedInstanceUpdatePreview { + shared_instance_id: attachment.id, + current_version: attachment.applied_version, + latest_version: version.version, + update_available: false, + diffs: Vec::new(), + })); + } + + let update_available = attachment + .applied_version + .is_none_or(|current| current < version.version); + let diffs = if update_available { + shared_instance_update_diffs(&metadata, &version, &state).await? + } else { + Vec::new() + }; + + Ok(Some(SharedInstanceUpdatePreview { + shared_instance_id: attachment.id, + current_version: attachment.applied_version, + latest_version: version.version, + update_available, + diffs, + })) +} + +pub(crate) async fn check_shared_instance_availability_before_launch( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance else { + return Ok(()); + }; + + let availability = + match get_remote_instance_access(&attachment.id, state).await { + Ok(availability) => availability, + Err(error) + if matches!( + error.raw.as_ref(), + crate::ErrorKind::NoCredentialsError + | crate::ErrorKind::FetchError(_) + ) => + { + tracing::warn!( + instance_id, + shared_instance_id = %attachment.id, + error = %error, + "Could not check shared instance availability before launch" + ); + return Ok(()); + } + Err(error) => return Err(error), + }; + + if let SharedInstanceRemoteResponse::Unavailable(reason) = availability { + handle_unavailable_shared_instance_if_current_user( + instance_id, + &attachment, + reason, + state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + + Ok(()) +} + +#[tracing::instrument] +pub async fn update_shared_instance( + instance_id: &str, +) -> crate::Result { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let attachment = metadata.shared_instance.clone().ok_or_else(|| { + crate::ErrorKind::InputError( + "Instance is not attached to a shared instance".to_string(), + ) + })?; + if !attachment.role.is_member() { + return Err(crate::ErrorKind::InputError( + "Only shared instance members can update from shared instances" + .to_string(), + ) + .into()); + } + + let version = + get_latest_remote_member_version(instance_id, &attachment, &state) + .await?; + let data = shared_instance_install_data( + &attachment.id, + attachment.manager_id.clone(), + attachment.server_manager_name.clone(), + attachment.server_manager_icon_url.clone(), + None, + metadata.instance.name, + version, + &state, + ) + .await?; + crate::install::update_shared_instance(instance_id.to_string(), data).await +} + +pub(super) async fn ensure_ready_remote_version_for_invite( + instance_id: &str, + attachment: &SharedInstanceAttachment, + state: &State, +) -> crate::Result<()> { + match get_latest_remote_version_optional_unavailable(&attachment.id, state) + .await? + { + SharedInstanceRemoteResponse::Available(_) => Ok(()), + SharedInstanceRemoteResponse::Unavailable( + SharedInstanceUnavailableReason::Deleted, + ) => { + tracing::info!( + instance_id, + shared_instance_id = %attachment.id, + "Shared instance has no ready version before invite; publishing current content" + ); + publish_shared_instance_inner(instance_id, &[], state).await + } + SharedInstanceRemoteResponse::Unavailable(reason) => { + handle_unavailable_shared_instance_if_current_user( + instance_id, + attachment, + reason, + state, + ) + .await?; + Err(shared_instance_unavailable_error(reason)) + } + } +} + +pub(super) async fn get_latest_remote_member_version( + instance_id: &str, + attachment: &SharedInstanceAttachment, + state: &State, +) -> crate::Result { + if let SharedInstanceRemoteResponse::Unavailable(reason) = + get_remote_instance_access(&attachment.id, state).await? + { + if shared_attachment_matches_current_user(attachment, state).await? { + handle_unavailable_shared_instance_if_current_user( + instance_id, + attachment, + reason, + state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + + return Err(crate::ErrorKind::OtherError(format!( + "{}: {}", + shared_instance_unavailable_message(reason), + attachment.id + )) + .into()); + } + + let version = match get_latest_remote_version_optional_unavailable( + &attachment.id, + state, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => version, + SharedInstanceRemoteResponse::Unavailable(reason) => { + if shared_attachment_matches_current_user(attachment, state).await? + { + handle_unavailable_shared_instance_if_current_user( + instance_id, + attachment, + reason, + state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + + return Err(crate::ErrorKind::OtherError(format!( + "{}: {}", + shared_instance_unavailable_message(reason), + attachment.id + )) + .into()); + } + }; + + Ok(version) +} + +pub(super) async fn shared_attachment_matches_current_user( + attachment: &SharedInstanceAttachment, + state: &State, +) -> crate::Result { + let Some(linked_user_id) = attachment.linked_user_id.as_deref() else { + return Ok(false); + }; + + Ok(linked_modrinth_user_id(state) + .await? + .as_deref() + .is_some_and(|current_user_id| current_user_id == linked_user_id)) +} + +pub(super) async fn has_shared_instance_recipients( + users: &SharedInstanceUsers, + attachment: &SharedInstanceAttachment, + has_pending_recipients: bool, + state: &State, +) -> crate::Result { + if has_pending_recipients || users.tokens > 0 { + return Ok(true); + } + + let current_user_id = linked_modrinth_user_id(state).await?; + + Ok(users.user_ids.iter().any(|user_id| { + Some(user_id.as_str()) != attachment.linked_user_id.as_deref() + && Some(user_id.as_str()) != attachment.manager_id.as_deref() + && Some(user_id.as_str()) != current_user_id.as_deref() + })) +} + +pub(super) async fn handle_unavailable_shared_instance_if_current_user( + instance_id: &str, + attachment: &SharedInstanceAttachment, + reason: SharedInstanceUnavailableReason, + state: &State, +) -> crate::Result<()> { + if reason != SharedInstanceUnavailableReason::Quarantined + && !shared_attachment_matches_current_user(attachment, state).await? + { + return Ok(()); + } + + match reason { + SharedInstanceUnavailableReason::Quarantined => { + crate::state::quarantine_shared_instance(instance_id, &state.pool) + .await?; + } + _ => { + crate::state::clear_shared_instance(instance_id, &state.pool) + .await?; + } + } + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(()) +} + +pub(super) fn shared_instance_unavailable_error( + reason: SharedInstanceUnavailableReason, +) -> crate::Error { + crate::ErrorKind::SharedInstanceUnavailable(reason).into() +} + +pub(super) fn shared_instance_unavailable_message( + reason: SharedInstanceUnavailableReason, +) -> &'static str { + match reason { + SharedInstanceUnavailableReason::Deleted => { + "Shared instance was deleted" + } + SharedInstanceUnavailableReason::AccessRevoked => { + "Shared instance access was revoked" + } + SharedInstanceUnavailableReason::Quarantined => { + "Shared instance was quarantined" + } + } +} + +fn shared_instance_external_file_data( + file: ExternalFileResponse, +) -> crate::Result { + let file_size = file.file_size.ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {} is missing its size", + file.file_name + )) + })?; + let file_size = u64::try_from(file_size).map_err(|_| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {} has an invalid size", + file.file_name + )) + })?; + + Ok(SharedInstanceExternalFileData { + file_name: file.file_name, + file_type: file.file_type, + url: file.url, + file_size, + }) +} + +pub(super) async fn shared_instance_install_data( + shared_instance_id: &str, + manager_id: Option, + server_manager_name: Option, + server_manager_icon_url: Option, + instance_icon_url: Option, + name: String, + version: InstanceVersionResponse, + state: &State, +) -> crate::Result { + if !version.ready { + return Err(crate::ErrorKind::InputError( + "Shared instance version is not ready to install".to_string(), + ) + .into()); + } + + let name = shared_instance_name(name); + let linked_user_id = linked_modrinth_user_id(state).await?; + let modpack = shared_instance_install_modpack(&version, state).await?; + let modpack_version_id = + modpack.as_ref().map(|modpack| modpack.version_id.as_str()); + let modrinth_ids = version + .modrinth_ids + .into_iter() + .filter(|id| Some(id.as_str()) != modpack_version_id) + .collect(); + + Ok(SharedInstanceInstallData { + shared_instance_id: shared_instance_id.to_string(), + manager_id, + server_manager_name, + server_manager_icon_url, + instance_icon_url, + linked_user_id, + name, + version: version.version, + modrinth_ids, + external_files: version + .external_files + .into_iter() + .map(shared_instance_external_file_data) + .collect::>>()?, + modpack, + game_version: version.game_version, + loader: version.loader, + loader_version: shared_instance_loader_version(version.loader_version), + }) +} + +pub(super) async fn linked_modrinth_user_id( + state: &State, +) -> crate::Result> { + Ok( + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + .map(|credentials| credentials.user_id), + ) +} diff --git a/packages/app-lib/src/api/instance/shared/invites.rs b/packages/app-lib/src/api/instance/shared/invites.rs new file mode 100644 index 000000000..d70f68901 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/invites.rs @@ -0,0 +1,184 @@ +const SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS: i32 = 604800; +const SHARED_INSTANCE_INVITE_MAX_USES: i32 = 10; + +use super::client::*; +use super::install::*; +use super::publish::*; +use super::types::*; +use super::*; + +#[tracing::instrument] +pub async fn get_shared_instance_users( + instance_id: &str, +) -> crate::Result { + let state = State::get().await?; + let Some(attachment) = shared_attachment(instance_id, &state).await? else { + return Ok(SharedInstanceUsers::empty()); + }; + + get_remote_users(&attachment.id, &state).await +} + +#[tracing::instrument] +pub async fn invite_shared_instance_users( + instance_id: &str, + user_ids: Vec, +) -> crate::Result { + let state = State::get().await?; + let (metadata, attachment) = + shared_instance_for_invites(instance_id, user_ids.len(), &state) + .await?; + + ensure_owner(&attachment)?; + if !user_ids.is_empty() { + ensure_ready_remote_version_for_invite( + instance_id, + &attachment, + &state, + ) + .await?; + update_remote_instance( + &attachment.id, + shared_instance_name(metadata.instance.name.clone()), + &state, + ) + .await?; + tracing::info!( + instance_id, + shared_instance_id = %attachment.id, + user_count = user_ids.len(), + "Adding users to shared instance" + ); + add_remote_users(&attachment.id, user_ids.clone(), &state).await?; + } + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + get_remote_users(&attachment.id, &state).await +} + +#[tracing::instrument(skip(replace_invite_id))] +pub async fn create_shared_instance_invite_link( + instance_id: &str, + max_age_seconds: Option, + max_uses: Option, + replace_invite_id: Option, +) -> crate::Result { + let state = State::get().await?; + let (metadata, attachment) = + shared_instance_for_invites(instance_id, 0, &state).await?; + ensure_owner(&attachment)?; + ensure_ready_remote_version_for_invite(instance_id, &attachment, &state) + .await?; + update_remote_instance( + &attachment.id, + shared_instance_name(metadata.instance.name), + &state, + ) + .await?; + + let max_age_seconds = + max_age_seconds.unwrap_or(SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS); + let max_uses = max_uses.unwrap_or(SHARED_INSTANCE_INVITE_MAX_USES); + if max_age_seconds <= 0 + || max_age_seconds > SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS + { + return Err(crate::ErrorKind::InputError( + "Invite expiry must be between now and seven days".to_string(), + ) + .into()); + } + if max_uses <= 0 { + return Err(crate::ErrorKind::InputError( + "Invite max uses must be greater than zero".to_string(), + ) + .into()); + } + + if let Some(invite_id) = replace_invite_id { + delete_remote_invite(&attachment.id, &invite_id, &state).await?; + } + + let created_at = Utc::now(); + + let response = request_json::( + "create_instance_invite", + Method::POST, + &format!("/instances/{}/invites", attachment.id), + Some(json!({ + "max_age": max_age_seconds, + "max_uses": max_uses, + })), + &state, + ) + .await?; + + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(SharedInstanceInviteLink { + invite_id: response.id, + expires_at: created_at + + chrono::Duration::seconds(i64::from(max_age_seconds)), + max_uses, + }) +} + +#[tracing::instrument] +pub async fn remove_shared_instance_users( + instance_id: &str, + user_ids: Vec, + has_pending_recipients: bool, +) -> crate::Result { + let state = State::get().await?; + let Some(attachment) = shared_attachment(instance_id, &state).await? else { + return Ok(SharedInstanceUsers::empty()); + }; + ensure_owner(&attachment)?; + + if !user_ids.is_empty() { + remove_remote_users(&attachment.id, user_ids, &state).await?; + } + + let remaining_users = get_remote_users(&attachment.id, &state).await?; + if !has_shared_instance_recipients( + &remaining_users, + &attachment, + has_pending_recipients, + &state, + ) + .await? + { + delete_remote_instance(&attachment.id, &state).await?; + crate::state::clear_shared_instance(instance_id, &state.pool).await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + return Ok(SharedInstanceUsers::empty()); + } + + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(remaining_users) +} + +#[tracing::instrument] +pub async fn accept_pending_shared_instance_invite( + shared_instance_id: &str, +) -> crate::Result<()> { + let state = State::get().await?; + + if accept_pending_remote_invite(shared_instance_id, &state).await? { + return Ok(()); + } + + Err(crate::ErrorKind::InputError( + "No pending invite found for shared instance".to_string(), + ) + .into()) +} + +#[tracing::instrument] +pub async fn decline_pending_shared_instance_invite( + shared_instance_id: &str, +) -> crate::Result<()> { + let state = State::get().await?; + decline_pending_remote_invite(shared_instance_id, &state).await +} diff --git a/packages/app-lib/src/api/instance/shared/mod.rs b/packages/app-lib/src/api/instance/shared/mod.rs new file mode 100644 index 000000000..5ebb1a13d --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/mod.rs @@ -0,0 +1,140 @@ +use super::content_set_diff::{ + ContentSetDiffEntry, ContentSetDiffKind, ContentSetDiffOptions, + ContentSetSnapshot, ContentSetSnapshotVersion, diff_content_sets, +}; +use crate::SharedInstanceUnavailableReason; +use crate::event::InstancePayloadType; +use crate::event::emit::emit_instance; +use crate::install::{ + InstallJobSnapshot, SharedInstanceExternalFileData, + SharedInstanceInstallData, +}; +use crate::state::instances::{InstanceLink, SharedInstanceAttachment}; +use crate::state::{ + AppliedContentSetPatch, CacheBehaviour, CachedEntry, ContentSetSyncStatus, + ContentSourceKind, EditInstance, ModLoader, ModrinthCredentials, + ProjectType, SharedInstanceRole, State, +}; +use crate::util::fetch::{INSECURE_REQWEST_CLIENT, REQWEST_CLIENT}; +use chrono::{DateTime, Utc}; +use reqwest::{Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::io::Read; + +pub(crate) const CONFIG_BUNDLE_FILE_NAME: &str = "configs.zip"; +pub(crate) const CONFIG_BUNDLE_FILE_TYPE: &str = "configs"; +pub(crate) const CONFIG_SYNC_ENABLED: bool = true; +pub(crate) const CONFIG_DIRECTORY: &str = "config"; +pub(crate) const MAX_CONFIG_BUNDLE_ENTRIES: usize = 4096; +pub(crate) const MAX_CONFIG_BUNDLE_FILE_SIZE: u64 = 16 * 1024 * 1024; +pub(crate) const MAX_CONFIG_BUNDLE_TOTAL_SIZE: u64 = 128 * 1024 * 1024; +pub(crate) const CONFIG_FILE_EXTENSIONS: [&str; 13] = [ + "json", + "json5", + "jsonc", + "yml", + "yaml", + "css", + "toml", + "txt", + "ini", + "cfg", + "conf", + "properties", + "xml", +]; + +pub(crate) fn read_bounded_config_bundle_entry( + reader: impl Read, + declared_size: u64, + total_size: &mut u64, +) -> crate::Result> { + let remaining = MAX_CONFIG_BUNDLE_TOTAL_SIZE.saturating_sub(*total_size); + let entry_limit = MAX_CONFIG_BUNDLE_FILE_SIZE.min(remaining); + if declared_size > entry_limit { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle exceeds the uncompressed size limit" + .to_string(), + ) + .into()); + } + + let capacity = usize::try_from(declared_size).map_err(|_| { + crate::ErrorKind::InputError( + "Shared instance config bundle entry is too large".to_string(), + ) + })?; + let mut bytes = Vec::with_capacity(capacity); + reader + .take(entry_limit.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Failed to read shared instance config bundle: {error}" + )) + })?; + let actual_size = bytes.len() as u64; + if actual_size > entry_limit { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle exceeds the uncompressed size limit" + .to_string(), + ) + .into()); + } + *total_size = total_size.checked_add(actual_size).ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance config bundle size overflowed".to_string(), + ) + })?; + + Ok(bytes) +} + +mod client; +mod diff; +mod install; +mod invites; +mod publish; +mod types; + +pub(crate) use self::install::check_shared_instance_availability_before_launch; +pub(crate) use self::publish::sync_shared_instance_icon; + +pub use self::install::{ + accept_shared_instance_invite_for_install, + get_shared_instance_install_preview, get_shared_instance_update_preview, + install_shared_instance, update_shared_instance, +}; +pub use self::invites::{ + accept_pending_shared_instance_invite, create_shared_instance_invite_link, + decline_pending_shared_instance_invite, get_shared_instance_users, + invite_shared_instance_users, remove_shared_instance_users, +}; +pub use self::publish::{ + get_shared_instance_publish_preview, publish_shared_instance, + unlink_shared_instance, unpublish_shared_instance, +}; +pub use self::types::{ + SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, + SharedInstanceInviteInstallPreview, SharedInstanceInviteLink, + SharedInstanceJoinType, SharedInstancePublishPreview, + SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, + SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, +}; + +pub async fn can_active_user_use_shared_instances() -> crate::Result { + let state = State::get().await?; + let Some(credentials) = + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + else { + return Ok(true); + }; + + let status = + client::get_user_blacklist_status(&credentials.user_id, &state).await?; + Ok(!status.blacklisted) +} diff --git a/packages/app-lib/src/api/instance/shared/publish.rs b/packages/app-lib/src/api/instance/shared/publish.rs new file mode 100644 index 000000000..b3af7666b --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/publish.rs @@ -0,0 +1,1163 @@ +use super::client::*; +use super::diff::*; +use super::install::*; +use super::types::*; +use super::*; +use async_walkdir::WalkDir; +use async_zip::{Compression, ZipEntryBuilder}; +use futures::StreamExt; +use std::collections::BTreeMap; + +#[tracing::instrument] +pub async fn unpublish_shared_instance(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance.clone() else { + return Ok(()); + }; + ensure_owner(&attachment)?; + + delete_remote_instance(&attachment.id, &state).await?; + detach_local_shared_instance(instance_id, metadata.link, &state).await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(()) +} + +#[tracing::instrument] +pub async fn unlink_shared_instance(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance.clone() else { + return Ok(()); + }; + ensure_member(&attachment)?; + + detach_local_shared_instance(instance_id, metadata.link, &state).await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(()) +} + +pub(super) async fn detach_local_shared_instance( + instance_id: &str, + link: InstanceLink, + state: &State, +) -> crate::Result<()> { + let link_patch = match link { + InstanceLink::SharedInstance { + modpack_project_id: Some(project_id), + modpack_version_id: Some(version_id), + } => Some(( + InstanceLink::ModrinthModpack { + project_id, + version_id, + }, + ContentSourceKind::ModrinthModpack, + )), + InstanceLink::SharedInstance { .. } => { + Some((InstanceLink::Unmanaged, ContentSourceKind::Local)) + } + _ => None, + }; + + if let Some((link, source_kind)) = link_patch { + crate::state::edit_instance( + instance_id, + EditInstance { + link: Some(link), + content_set_patch: Some(AppliedContentSetPatch { + source_kind: Some(source_kind), + ..Default::default() + }), + ..Default::default() + }, + &state.pool, + ) + .await?; + } + + crate::state::clear_shared_instance(instance_id, &state.pool).await?; + + Ok(()) +} + +#[tracing::instrument] +pub async fn publish_shared_instance( + instance_id: &str, + config_paths: Vec, +) -> crate::Result { + let state = State::get().await?; + publish_shared_instance_inner(instance_id, &config_paths, &state).await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + shared_attachment(instance_id, &state) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance attachment was not persisted".to_string(), + ) + .into() + }) +} + +#[tracing::instrument] +pub async fn get_shared_instance_publish_preview( + instance_id: &str, +) -> crate::Result> { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance.clone() else { + return Ok(None); + }; + ensure_owner(&attachment)?; + + let (version, snapshot) = tokio::try_join!( + get_latest_remote_version_optional_unavailable(&attachment.id, &state,), + collect_publish_snapshot(&metadata, &state), + )?; + let version = match version { + SharedInstanceRemoteResponse::Available(version) => version, + SharedInstanceRemoteResponse::Unavailable(reason) => { + handle_unavailable_shared_instance_if_current_user( + instance_id, + &attachment, + reason, + &state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + }; + let diffs = + shared_instance_publish_diffs(&metadata, &version, &snapshot, &state) + .await?; + set_shared_instance_publish_status( + instance_id, + &attachment, + version.version, + !diffs.is_empty(), + &state, + ) + .await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(Some(SharedInstancePublishPreview { + shared_instance_id: attachment.id, + latest_version: version.version, + diffs, + config_files: snapshot + .config_files + .into_iter() + .map(|file| file.path) + .collect(), + })) +} + +pub(super) async fn remote_publish_content( + version: &InstanceVersionResponse, + include_modpack_dependencies: bool, + state: &State, +) -> crate::Result<(Vec, HashSet)> { + let mut version_ids = version.modrinth_ids.clone(); + if let Some(modpack_id) = + version.modpack_id.as_deref().filter(|id| !id.is_empty()) + { + version_ids.retain(|id| id != modpack_id); + + if include_modpack_dependencies { + version_ids.extend( + modpack_dependency_version_ids(modpack_id, state).await?, + ); + } + } + dedupe_strings(&mut version_ids); + + Ok(( + version_ids, + version + .external_files + .iter() + .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .map(|file| file.file_name.clone()) + .collect(), + )) +} + +pub(super) async fn modpack_dependency_version_ids( + modpack_id: &str, + state: &State, +) -> crate::Result> { + let modpack_version = CachedEntry::get_version( + modpack_id, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance modpack version was not found".to_string(), + ) + })?; + + Ok(modpack_version + .dependencies + .into_iter() + .filter_map(|dependency| dependency.version_id) + .collect()) +} + +pub(super) async fn shared_instance_install_modpack( + version: &InstanceVersionResponse, + state: &State, +) -> crate::Result> { + let Some(modpack_id) = + version.modpack_id.as_deref().filter(|id| !id.is_empty()) + else { + return Ok(None); + }; + let modpack_version = CachedEntry::get_version( + modpack_id, + Some(CacheBehaviour::MustRevalidate), + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance modpack version was not found".to_string(), + ) + })?; + let project = CachedEntry::get_project( + &modpack_version.project_id, + Some(CacheBehaviour::MustRevalidate), + &state.pool, + &state.api_semaphore, + ) + .await?; + + Ok(Some(crate::install::SharedInstanceInstallModpack { + project_id: modpack_version.project_id, + version_id: modpack_version.id, + dependency_count: modpack_version.dependencies.len(), + title: project + .as_ref() + .map(|project| project.title.clone()) + .unwrap_or(modpack_version.name), + icon_url: project.and_then(|project| project.icon_url), + })) +} + +pub(super) fn shared_instance_loader_version( + loader_version: String, +) -> Option { + (!loader_version.is_empty()).then_some(loader_version) +} + +pub(super) async fn current_shared_content( + metadata: &crate::state::InstanceMetadata, + include_linked_modpack_content: bool, + state: &State, +) -> crate::Result<(Vec, HashSet)> { + let entries = + crate::state::instances::adapters::sqlite::content_rows::get_content_entries( + &metadata.applied_content_set.id, + &state.pool, + ) + .await?; + let files = crate::state::instances::adapters::sqlite::content_rows::get_instance_files( + &metadata.instance.id, + &state.pool, + ) + .await? + .into_iter() + .map(|file| (file.id.clone(), file)) + .collect::>(); + let mut version_ids = Vec::new(); + let mut external_files = HashSet::new(); + + for entry in entries { + let include_entry = entry.source_kind + == crate::state::ContentSourceKind::SharedInstance + || (include_linked_modpack_content + && entry.source_kind + == crate::state::ContentSourceKind::ModrinthModpack); + if !include_entry { + continue; + } + + if let Some(version_id) = entry.version_id { + version_ids.push(version_id); + continue; + } + + let Some(file_id) = entry.file_id else { + continue; + }; + if let Some(file) = files.get(&file_id) { + external_files.insert(file.file_name.clone()); + } + } + if include_linked_modpack_content + && let Some(modpack_id) = shared_modpack_id(&metadata.link) + { + version_ids + .extend(modpack_dependency_version_ids(&modpack_id, state).await?); + } + dedupe_strings(&mut version_ids); + + Ok((version_ids, external_files)) +} + +pub(super) struct CurrentPublishSnapshot { + pub(super) version_ids: Vec, + pub(super) external_files: Vec, + pub(super) disabled_project_ids: HashSet, + pub(super) disabled_version_ids: Vec, + pub(super) disabled_external_files: HashSet, + pub(super) config_files: Vec, +} + +pub(super) async fn collect_publish_snapshot( + metadata: &crate::state::InstanceMetadata, + state: &State, +) -> crate::Result { + let instance_path = state + .directories + .instances_dir() + .join(&metadata.instance.path); + let (items, config_files) = if CONFIG_SYNC_ENABLED { + tokio::try_join!( + crate::state::list_content( + &metadata.instance.id, + None, + None, + state, + ), + collect_config_files(&instance_path), + )? + } else { + ( + crate::state::list_content( + &metadata.instance.id, + None, + None, + state, + ) + .await?, + Vec::new(), + ) + }; + let modpack_id = shared_modpack_id(&metadata.link); + let mut version_ids = Vec::new(); + let mut seen_version_ids = HashSet::new(); + let mut external_files = Vec::new(); + let mut seen_external_files = HashSet::new(); + let mut disabled_project_ids = HashSet::new(); + let mut disabled_version_ids = Vec::new(); + let mut seen_disabled_version_ids = HashSet::new(); + let mut disabled_external_files = HashSet::new(); + + for item in items { + if item.enabled { + if let Some(version) = item.version { + if seen_version_ids.insert(version.id.clone()) { + version_ids.push(version.id); + } + continue; + } + + if item.file_path.is_empty() { + continue; + } + + let file_type = file_type(item.project_type); + let external_key = format!("{}:{file_type}", item.file_path); + if seen_external_files.insert(external_key) { + external_files.push(ExternalFileCandidate { + file_name: item.file_name, + file_type, + source: ExternalFileSource::InstanceFile(item.file_path), + }); + } + continue; + } + + let is_modpack = item.version.as_ref().is_some_and(|version| { + modpack_id.as_deref() == Some(version.id.as_str()) + }); + if is_modpack { + continue; + } + + if let Some(project) = item.project.as_ref() { + disabled_project_ids.insert(project.id.clone()); + } + + if let Some(version) = item.version { + if seen_disabled_version_ids.insert(version.id.clone()) { + disabled_version_ids.push(version.id); + } + continue; + } + + if item.file_path.is_empty() { + continue; + } + + disabled_external_files.insert(enabled_file_name(&item.file_name)); + } + + Ok(CurrentPublishSnapshot { + version_ids, + external_files, + disabled_project_ids, + disabled_version_ids, + disabled_external_files, + config_files, + }) +} + +pub(super) async fn shared_versions_by_project( + version_ids: &[String], + state: &State, +) -> crate::Result> { + let version_id_refs = + version_ids.iter().map(String::as_str).collect::>(); + let versions = CachedEntry::get_version_many( + &version_id_refs, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await?; + + Ok(versions + .into_iter() + .map(|version| (version.project_id.clone(), version)) + .collect()) +} + +pub(super) async fn shared_project_names( + project_ids: &HashSet, + state: &State, +) -> crate::Result> { + let project_id_refs = + project_ids.iter().map(String::as_str).collect::>(); + let projects = CachedEntry::get_project_many( + &project_id_refs, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await?; + + Ok(projects + .into_iter() + .map(|project| (project.id, project.title)) + .collect()) +} + +pub(super) fn dedupe_strings(values: &mut Vec) { + let mut seen = HashSet::new(); + values.retain(|value| seen.insert(value.clone())); +} + +pub(super) fn enabled_file_name(file_name: &str) -> String { + file_name + .strip_suffix(".disabled") + .unwrap_or(file_name) + .to_string() +} + +pub(super) fn shared_instance_name(name: String) -> String { + match name.trim() { + "" => "Shared instance".to_string(), + name => name.to_string(), + } +} + +pub(super) async fn set_shared_instance_publish_status( + instance_id: &str, + attachment: &SharedInstanceAttachment, + latest_version: i32, + has_changes: bool, + state: &State, +) -> crate::Result<()> { + let (status, applied_version) = if has_changes { + (ContentSetSyncStatus::Stale, attachment.applied_version) + } else { + (ContentSetSyncStatus::UpToDate, Some(latest_version)) + }; + + crate::state::set_shared_instance_sync_status( + instance_id, + status, + applied_version, + Some(latest_version), + &state.pool, + ) + .await +} + +pub(super) async fn publish_shared_instance_inner( + instance_id: &str, + config_paths: &[String], + state: &State, +) -> crate::Result<()> { + let attachment = + shared_attachment(instance_id, state) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Instance is not attached to a shared instance".to_string(), + ) + })?; + ensure_owner(&attachment)?; + tracing::info!( + instance_id, + shared_instance_id = %attachment.id, + applied_version = attachment.applied_version, + latest_version = attachment.latest_version, + "Publishing shared instance content" + ); + + crate::state::set_shared_instance_sync_status( + instance_id, + ContentSetSyncStatus::Applying, + attachment.applied_version, + attachment.latest_version, + &state.pool, + ) + .await?; + + let result = publish_current_content( + instance_id, + &attachment.id, + config_paths, + state, + ) + .await; + + match result { + Ok(version) => { + tracing::info!( + instance_id, + shared_instance_id = %attachment.id, + version, + "Published shared instance content" + ); + crate::state::set_shared_instance_sync_status( + instance_id, + ContentSetSyncStatus::UpToDate, + Some(version), + Some(version), + &state.pool, + ) + .await?; + Ok(()) + } + Err(error) => { + tracing::warn!( + instance_id, + shared_instance_id = %attachment.id, + error = %error, + "Failed to publish shared instance content" + ); + crate::state::set_shared_instance_sync_status( + instance_id, + ContentSetSyncStatus::Error, + attachment.applied_version, + attachment.latest_version, + &state.pool, + ) + .await?; + Err(error) + } + } +} + +pub(super) async fn publish_current_content( + instance_id: &str, + shared_instance_id: &str, + config_paths: &[String], + state: &State, +) -> crate::Result { + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + ensure_shareable_link(&metadata.link)?; + update_remote_instance( + shared_instance_id, + shared_instance_name(metadata.instance.name.clone()), + state, + ) + .await?; + let modpack_id = shared_modpack_id(&metadata.link); + let snapshot = collect_publish_snapshot(&metadata, state).await?; + let previous_version = if CONFIG_SYNC_ENABLED + && metadata + .shared_instance + .as_ref() + .and_then(|attachment| attachment.applied_version) + .is_some() + { + match get_latest_remote_version_optional_unavailable( + shared_instance_id, + state, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => Some(version), + SharedInstanceRemoteResponse::Unavailable(_) => None, + } + } else { + None + }; + let config_bundle = if CONFIG_SYNC_ENABLED { + build_config_bundle_candidate( + &metadata.instance.path, + &snapshot.config_files, + config_paths, + previous_version.as_ref(), + state, + ) + .await? + } else { + None + }; + let modrinth_ids = snapshot.version_ids; + let mut external_files = snapshot.external_files; + if let Some(config_bundle) = config_bundle { + external_files.push(config_bundle); + } + tracing::debug!( + instance_id, + shared_instance_id, + modpack_id = modpack_id.as_deref().unwrap_or("none"), + modrinth_id_count = modrinth_ids.len(), + external_file_count = external_files.len(), + "Creating shared instance version" + ); + let external_file_data = external_files + .iter() + .map(|file| ExternalFileData { + file_name: file.file_name.clone(), + file_type: file.file_type.clone(), + }) + .collect::>(); + let response = request_json_optional_unavailable::( + "create_instance_version", + Method::POST, + &format!("/instances/{shared_instance_id}/versions"), + Some(json!({ + "modrinth_ids": modrinth_ids, + "external_files": external_file_data, + "modpack_id": modpack_id, + "game_version": metadata.applied_content_set.game_version.clone(), + "loader": metadata.applied_content_set.loader.as_str(), + "loader_version": metadata + .applied_content_set + .loader_version + .clone() + .unwrap_or_default(), + })), + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await?; + let response = match response { + SharedInstanceRemoteResponse::Available(response) => response, + SharedInstanceRemoteResponse::Unavailable(reason) => { + if let Some(attachment) = metadata.shared_instance.as_ref() { + handle_unavailable_shared_instance_if_current_user( + instance_id, + attachment, + reason, + state, + ) + .await?; + } + + return Err(shared_instance_unavailable_error(reason)); + } + }; + + if !response.external_files.is_empty() { + upload_external_files( + &metadata.instance.path, + &external_files, + &response.external_files, + state, + ) + .await?; + } else if !response.ready { + tracing::debug!( + "Shared instance version {} was not ready but had no external files", + response.version + ); + } + + Ok(response.version) +} + +pub(super) async fn collect_config_files( + instance_path: &std::path::Path, +) -> crate::Result> { + let config_path = instance_path.join(CONFIG_DIRECTORY); + crate::util::io::create_dir_all(&config_path).await?; + let mut files = Vec::new(); + let mut walker = WalkDir::new(&config_path); + + while let Some(entry) = walker.next().await { + let entry = entry.map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Failed to read config directory: {error}" + )) + })?; + if !entry.file_type().await?.is_file() + || !is_supported_config_file(&entry.path()) + { + continue; + } + + let entry_path = entry.path(); + let relative_path = entry_path.strip_prefix(&config_path)?; + let path = relative_path.to_string_lossy().replace('\\', "/"); + files.push(ConfigFile { path }); + } + + files.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(files) +} + +fn is_supported_config_file(path: &std::path::Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + CONFIG_FILE_EXTENSIONS + .iter() + .any(|candidate| extension.eq_ignore_ascii_case(candidate)) + }) +} + +async fn build_config_bundle_candidate( + instance_path: &str, + local_files: &[ConfigFile], + selected_paths: &[String], + previous_version: Option<&InstanceVersionResponse>, + state: &State, +) -> crate::Result> { + let selected_paths = selected_paths + .iter() + .map(String::as_str) + .collect::>(); + let local_files_by_path = local_files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect::>(); + for selected_path in &selected_paths { + if !local_files_by_path.contains_key(selected_path) { + return Err(crate::ErrorKind::InputError(format!( + "Config file is unavailable for sharing: {selected_path}" + )) + .into()); + } + } + + let previous_bundle = previous_version.and_then(|version| { + version + .external_files + .iter() + .find(|file| file.file_type == CONFIG_BUNDLE_FILE_TYPE) + }); + if previous_bundle.is_none() && selected_paths.is_empty() { + return Ok(None); + } + + let mut entries = BTreeMap::new(); + if let (Some(_), Some(previous_bundle)) = + (previous_version, previous_bundle) + { + let response = REQWEST_CLIENT.get(&previous_bundle.url).send().await?; + if !response.status().is_success() { + return Err(crate::ErrorKind::OtherError(format!( + "Previous config bundle download failed with status {}", + response.status() + )) + .into()); + } + let bytes = response.bytes().await?; + let archived_entries = tokio::task::spawn_blocking(move || { + read_config_bundle(bytes.as_ref()) + }) + .await??; + entries.extend(archived_entries); + } + + let config_path = state + .directories + .instances_dir() + .join(instance_path) + .join(CONFIG_DIRECTORY); + for selected_path in selected_paths { + let file = local_files_by_path + .get(selected_path) + .expect("selected config paths were validated"); + let bytes = crate::util::io::read(config_path.join(&file.path)).await?; + entries.insert(file.path.clone(), bytes); + } + + let bytes = config_bundle_bytes(&entries).await?; + + Ok(Some(ExternalFileCandidate { + file_name: CONFIG_BUNDLE_FILE_NAME.to_string(), + file_type: CONFIG_BUNDLE_FILE_TYPE.to_string(), + source: ExternalFileSource::ConfigBundle(bytes), + })) +} + +async fn config_bundle_bytes( + entries: &BTreeMap>, +) -> crate::Result> { + if entries.len() > MAX_CONFIG_BUNDLE_ENTRIES { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle contains too many entries" + .to_string(), + ) + .into()); + } + let mut total_size = 0_u64; + for bytes in entries.values() { + let size = bytes.len() as u64; + if size > MAX_CONFIG_BUNDLE_FILE_SIZE { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle contains a file that is too large" + .to_string(), + ) + .into()); + } + total_size = total_size.checked_add(size).ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance config bundle size overflowed".to_string(), + ) + })?; + if total_size > MAX_CONFIG_BUNDLE_TOTAL_SIZE { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle exceeds the uncompressed size limit" + .to_string(), + ) + .into()); + } + } + + let mut writer = async_zip::base::write::ZipFileWriter::new(Vec::new()); + for (path, bytes) in entries { + writer + .write_entry_whole( + ZipEntryBuilder::new(path.clone().into(), Compression::Deflate), + bytes, + ) + .await?; + } + + Ok(writer.close().await?) +} + +fn read_config_bundle( + bytes: &[u8], +) -> crate::Result>> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|error| { + crate::ErrorKind::InputError(format!( + "Invalid shared instance config bundle: {error}" + )) + })?; + if archive.len() > MAX_CONFIG_BUNDLE_ENTRIES { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle contains too many entries" + .to_string(), + ) + .into()); + } + let mut entries = BTreeMap::new(); + let mut total_size = 0; + + for index in 0..archive.len() { + let file = archive.by_index(index).map_err(|error| { + crate::ErrorKind::InputError(format!( + "Invalid shared instance config bundle entry: {error}" + )) + })?; + if file.is_dir() { + continue; + } + let path = file.enclosed_name().ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance config bundle contains an unsafe path" + .to_string(), + ) + })?; + if !is_supported_config_file(&path) { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance config bundle contains unsupported file {}", + path.display() + )) + .into()); + } + let path = path.to_string_lossy().replace('\\', "/"); + let declared_size = file.size(); + let bytes = read_bounded_config_bundle_entry( + file, + declared_size, + &mut total_size, + )?; + if entries.insert(path.clone(), bytes).is_some() { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance config bundle contains duplicate file {path}" + )) + .into()); + } + } + + Ok(entries) +} + +pub(super) fn shared_modpack_id(link: &InstanceLink) -> Option { + match link { + InstanceLink::ModrinthModpack { version_id, .. } => { + Some(version_id.clone()) + } + InstanceLink::ServerProjectModpack { + content_version_id, .. + } => Some(content_version_id.clone()), + InstanceLink::SharedInstance { + modpack_version_id: Some(version_id), + .. + } => Some(version_id.clone()), + _ => None, + } +} + +pub(super) fn ensure_shareable_link(link: &InstanceLink) -> crate::Result<()> { + if matches!(link, InstanceLink::ImportedModpack { .. }) { + return Err(crate::ErrorKind::InputError( + "You must unlink this modpack to share your instance".to_string(), + ) + .into()); + } + + Ok(()) +} + +pub(super) async fn upload_external_files( + instance_path: &str, + candidates: &[ExternalFileCandidate], + uploads: &[ExternalFileResponse], + state: &State, +) -> crate::Result<()> { + for upload in uploads { + let candidate = candidates + .iter() + .find(|candidate| { + candidate.file_name == upload.file_name + && candidate.file_type == upload.file_type + }) + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Shared instance service requested unknown external file {}", + upload.file_name + )) + })?; + let bytes = match &candidate.source { + ExternalFileSource::InstanceFile(file_path) => { + let path = state + .directories + .instances_dir() + .join(instance_path) + .join(file_path); + crate::util::io::read(path).await? + } + ExternalFileSource::ConfigBundle(bytes) => bytes.clone(), + }; + let upload_url = url::Url::parse(&upload.url).map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Invalid shared instance external file upload URL: {error}" + )) + })?; + let response = send_bytes_request_to_url( + "upload_external_file", + Method::PUT, + upload_url.path(), + &upload.url, + bytes, + state, + ) + .await?; + + if !response.status().is_success() { + return shared_instances_request_error( + "upload_external_file", + Method::PUT, + upload_url.path(), + response, + ) + .await; + } + } + + Ok(()) +} + +pub(super) async fn shared_attachment( + instance_id: &str, + state: &State, +) -> crate::Result> { + Ok(crate::state::get_instance(instance_id, &state.pool) + .await? + .and_then(|metadata| metadata.shared_instance)) +} + +pub(crate) async fn sync_shared_instance_icon( + instance_id: &str, + icon_path: Option<&str>, + state: &State, +) -> crate::Result<()> { + let Some(attachment) = shared_attachment(instance_id, state).await? else { + return Ok(()); + }; + if attachment.role != SharedInstanceRole::Owner { + return Ok(()); + } + + update_remote_instance_icon(&attachment.id, icon_path, state).await +} + +pub(super) async fn shared_instance_for_invites( + instance_id: &str, + user_count: usize, + state: &State, +) -> crate::Result<(crate::state::InstanceMetadata, SharedInstanceAttachment)> { + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let attachment = match metadata.shared_instance.clone() { + Some(attachment) => { + tracing::debug!( + instance_id, + shared_instance_id = %attachment.id, + role = attachment.role.as_str(), + user_count, + "Using existing shared instance attachment for invite" + ); + attachment + } + None => { + ensure_shareable_link(&metadata.link)?; + tracing::info!( + instance_id, + user_count, + "Creating shared instance before first invite" + ); + let remote = create_remote_instance( + shared_instance_name(metadata.instance.name.clone()), + state, + ) + .await?; + let linked_user_id = linked_modrinth_user_id(state).await?; + tracing::info!( + instance_id, + shared_instance_id = %remote.id, + "Created remote shared instance" + ); + crate::state::attach_shared_instance( + instance_id, + crate::state::SharedInstanceAttachmentInput { + id: remote.id.clone(), + role: SharedInstanceRole::Owner, + manager_id: None, + server_manager_name: None, + server_manager_icon_url: None, + linked_user_id, + status: ContentSetSyncStatus::Unknown, + applied_version: None, + latest_version: None, + }, + &state.pool, + ) + .await?; + tracing::debug!( + instance_id, + shared_instance_id = %remote.id, + "Attached local instance as shared instance owner" + ); + publish_shared_instance_inner(instance_id, &[], state).await?; + shared_attachment(instance_id, state) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance attachment was not persisted" + .to_string(), + ) + })? + } + }; + + ensure_owner(&attachment)?; + update_remote_instance_icon( + &attachment.id, + metadata.instance.icon_path.as_deref(), + state, + ) + .await?; + + Ok((metadata, attachment)) +} + +pub(super) fn ensure_owner( + attachment: &SharedInstanceAttachment, +) -> crate::Result<()> { + if attachment.role == SharedInstanceRole::Owner { + return Ok(()); + } + + Err(crate::ErrorKind::InputError( + "Only the owner instance can manage shared instance users".to_string(), + ) + .into()) +} + +pub(super) fn ensure_member( + attachment: &SharedInstanceAttachment, +) -> crate::Result<()> { + if attachment.role.is_member() { + return Ok(()); + } + + Err(crate::ErrorKind::InputError( + "Only shared instance members can unlink from shared instances" + .to_string(), + ) + .into()) +} + +pub(super) fn file_type(project_type: ProjectType) -> String { + project_type.get_name().to_string() +} diff --git a/packages/app-lib/src/api/instance/shared/types.rs b/packages/app-lib/src/api/instance/shared/types.rs new file mode 100644 index 000000000..05a98d788 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/types.rs @@ -0,0 +1,162 @@ +use super::*; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SharedInstanceUsers { + pub user_ids: Vec, + #[serde(default)] + pub users: Vec, + #[serde(default)] + pub tokens: i32, +} + +impl SharedInstanceUsers { + pub(super) fn empty() -> Self { + Self { + user_ids: Vec::new(), + users: Vec::new(), + tokens: 0, + } + } + + pub(super) fn from_users( + users: Vec, + tokens: i32, + ) -> Self { + let user_ids = users.iter().map(|user| user.id.clone()).collect(); + + Self { + user_ids, + users, + tokens, + } + } + + pub(super) fn from_user_ids(user_ids: Vec) -> Self { + let users = user_ids + .iter() + .map(|user_id| SharedInstanceUser { + id: user_id.clone(), + joined_at: None, + join_type: SharedInstanceJoinType::Invite, + last_played: None, + }) + .collect(); + + Self { + user_ids, + users, + tokens: 0, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SharedInstanceUser { + pub id: String, + pub joined_at: Option>, + pub join_type: SharedInstanceJoinType, + pub last_played: Option>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharedInstanceJoinType { + Owner, + Invite, + Link, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceInstallPreview { + pub shared_instance_id: String, + pub version: i32, + pub name: String, + pub icon_url: Option, + pub game_version: String, + pub loader: ModLoader, + pub mod_count: usize, + pub external_file_count: usize, + pub modpack_version_id: Option, + pub content_version_ids: Vec, + pub external_files: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceExternalFilePreview { + pub file_name: String, + pub file_type: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceUpdatePreview { + pub shared_instance_id: String, + pub current_version: Option, + pub latest_version: i32, + pub update_available: bool, + pub diffs: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstancePublishPreview { + pub shared_instance_id: String, + pub latest_version: i32, + pub diffs: Vec, + pub config_files: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceInviteLink { + pub invite_id: String, + pub expires_at: DateTime, + pub max_uses: i32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceInviteInstallPreview { + pub shared_instance_id: String, + pub manager_id: Option, + pub server_manager_name: Option, + pub server_manager_icon_url: Option, + pub instance_icon_url: Option, + pub preview: SharedInstanceInstallPreview, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceUpdateDiff { + #[serde(rename = "type")] + pub type_: SharedInstanceUpdateDiffType, + pub project_id: Option, + pub project_name: Option, + pub file_name: Option, + pub current_version_name: Option, + pub new_version_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_file_count: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub disabled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SharedInstanceUpdateDiffType { + Added, + Removed, + Updated, + ModpackLinked, + ModpackUpdated, + ModpackUnlinked, + GameVersionUpdated, + LoaderUpdated, + ConfigFilesUpdated, +} + +pub(super) fn is_false(value: &bool) -> bool { + !*value +} diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 927a666aa..96d5928ed 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -11,9 +11,11 @@ pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; +pub mod reports; pub mod server_address; pub mod settings; pub mod tags; +pub mod users; pub mod worlds; pub mod data { @@ -26,7 +28,8 @@ pub mod data { JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader, ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3, - Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize, + Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember, + Theme, User, UserFriend, Version, WindowSize, }; pub use ariadne::users::UserStatus; pub use modrinth_content_management::{ diff --git a/packages/app-lib/src/api/mr_auth.rs b/packages/app-lib/src/api/mr_auth.rs index 42ce41fe5..428a63a65 100644 --- a/packages/app-lib/src/api/mr_auth.rs +++ b/packages/app-lib/src/api/mr_auth.rs @@ -1,8 +1,19 @@ use crate::state::ModrinthCredentials; +use serde::Deserialize; + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ModrinthAuthFlow { + SignIn, + SignUp, +} #[tracing::instrument] -pub fn authenticate_begin_flow() -> &'static str { - crate::state::get_login_url() +pub fn authenticate_begin_flow(flow: ModrinthAuthFlow) -> &'static str { + match flow { + ModrinthAuthFlow::SignIn => crate::state::get_login_url(), + ModrinthAuthFlow::SignUp => crate::state::get_signup_url(), + } } #[tracing::instrument] diff --git a/packages/app-lib/src/api/reports.rs b/packages/app-lib/src/api/reports.rs new file mode 100644 index 000000000..75e13cb28 --- /dev/null +++ b/packages/app-lib/src/api/reports.rs @@ -0,0 +1,46 @@ +use crate::State; +use crate::util::fetch::fetch_json; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ReportItemType { + Project, + Version, + User, + SharedInstance, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CreateReportRequest { + pub report_type: String, + pub item_id: String, + pub item_type: ReportItemType, + pub body: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub uploaded_images: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CreateReportResponse { + pub id: String, +} + +#[tracing::instrument(skip(request))] +pub async fn create_report( + request: CreateReportRequest, +) -> crate::Result { + let state = State::get().await?; + + fetch_json( + Method::POST, + &format!("{}report", env!("MODRINTH_API_URL_V3")), + None, + Some(serde_json::to_value(request)?), + Some("/v3/report"), + &state.api_semaphore, + &state.pool, + ) + .await +} diff --git a/packages/app-lib/src/api/users.rs b/packages/app-lib/src/api/users.rs new file mode 100644 index 000000000..6d5f4019f --- /dev/null +++ b/packages/app-lib/src/api/users.rs @@ -0,0 +1,32 @@ +use crate::State; +use crate::util::fetch::fetch_json; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SearchUser { + pub id: String, + pub username: String, + pub avatar_url: Option, +} + +#[tracing::instrument] +pub async fn search_user(query: &str) -> crate::Result> { + let state = State::get().await?; + let query = urlencoding::encode(query); + + fetch_json( + Method::GET, + &format!( + "{}users/search?query={}", + env!("MODRINTH_API_URL_V3"), + query + ), + None, + None, + Some("/v3/users/search"), + &state.api_semaphore, + &state.pool, + ) + .await +} diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 4ab04cea1..ad7dca9e5 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -22,6 +22,24 @@ pub struct LabrinthError { pub route: Option, } +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SharedInstanceUnavailableReason { + Deleted, + AccessRevoked, + Quarantined, +} + +impl std::fmt::Display for SharedInstanceUnavailableReason { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Deleted => write!(fmt, "deleted"), + Self::AccessRevoked => write!(fmt, "access_revoked"), + Self::Quarantined => write!(fmt, "quarantined"), + } + } +} + #[derive(thiserror::Error, Debug)] pub enum ErrorKind { #[error("{0:?}")] @@ -103,6 +121,9 @@ pub enum ErrorKind { #[error("Invalid input: {0}")] InputError(String), + #[error("Shared instance unavailable: {0}")] + SharedInstanceUnavailable(SharedInstanceUnavailableReason), + #[error("Join handle error: {0}")] JoinError(#[from] tokio::task::JoinError), diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index e61dded89..3a1dabb95 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -234,6 +234,9 @@ pub enum CommandPayload { server: Option, singleplayer_world: Option, }, + InstallSharedInstanceInvite { + invite_id: String, + }, RunMRPack { // run or install .mrpack path: PathBuf, diff --git a/packages/app-lib/src/install/mod.rs b/packages/app-lib/src/install/mod.rs index bc8ba02f8..ec6844bda 100644 --- a/packages/app-lib/src/install/mod.rs +++ b/packages/app-lib/src/install/mod.rs @@ -3,6 +3,7 @@ pub mod events; pub mod model; pub mod recovery; pub mod runner; +mod shared_instance; pub mod store; pub use events::InstallProgressReporter; @@ -11,11 +12,13 @@ pub use model::{ InstallJobEventKind, InstallJobKind, InstallJobSnapshot, InstallJobStatus, InstallModpackPreview, InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit, InstallProgress, InstallProgressSecondary, - InstallRequest, + InstallRequest, SharedInstanceExternalFileData, SharedInstanceInstallData, + SharedInstanceInstallModpack, }; pub use runner::{ - cancel_job, create_instance, create_modpack_instance, dismiss_job, - duplicate_instance, get_job, import_instance, install_existing_instance, + cancel_job, create_instance, create_modpack_instance, + create_shared_instance, dismiss_job, duplicate_instance, get_job, + import_instance, install_existing_instance, install_pack_to_existing_instance, job_support_details, list_jobs, - retry_job, + retry_job, update_shared_instance, }; diff --git a/packages/app-lib/src/install/model.rs b/packages/app-lib/src/install/model.rs index 434654214..4f49c1db9 100644 --- a/packages/app-lib/src/install/model.rs +++ b/packages/app-lib/src/install/model.rs @@ -169,6 +169,9 @@ pub enum InstallRequest { #[serde(default)] post_install_edit: Option, }, + CreateSharedInstance { + data: SharedInstanceInstallData, + }, ImportInstance { launcher_type: ImportLauncherType, base_path: PathBuf, @@ -187,6 +190,10 @@ pub enum InstallRequest { #[serde(default)] post_install_edit: Option, }, + UpdateSharedInstance { + instance_id: String, + data: SharedInstanceInstallData, + }, } #[derive(Serialize, Deserialize, Clone, Debug, Default)] @@ -201,6 +208,46 @@ pub struct InstallPostInstallEdit { pub link: Option, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SharedInstanceInstallData { + pub shared_instance_id: String, + pub manager_id: Option, + #[serde(default)] + pub server_manager_name: Option, + #[serde(default)] + pub server_manager_icon_url: Option, + #[serde(default)] + pub instance_icon_url: Option, + #[serde(default)] + pub linked_user_id: Option, + pub name: String, + pub version: i32, + pub modrinth_ids: Vec, + #[serde(default)] + pub external_files: Vec, + pub modpack: Option, + pub game_version: String, + pub loader: ModLoader, + pub loader_version: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SharedInstanceExternalFileData { + pub file_name: String, + pub file_type: String, + pub url: String, + pub file_size: u64, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SharedInstanceInstallModpack { + pub project_id: String, + pub version_id: String, + pub title: String, + pub icon_url: Option, + pub dependency_count: usize, +} + impl InstallRequest { pub fn kind(&self) -> InstallJobKind { match self { @@ -208,6 +255,9 @@ impl InstallRequest { Self::CreateModpackInstance { .. } => { InstallJobKind::CreateModpackInstance } + Self::CreateSharedInstance { .. } => { + InstallJobKind::CreateSharedInstance + } Self::ImportInstance { .. } => InstallJobKind::ImportInstance, Self::DuplicateInstance { .. } => InstallJobKind::DuplicateInstance, Self::InstallExistingInstance { .. } => { @@ -216,13 +266,17 @@ impl InstallRequest { Self::InstallPackToExistingInstance { .. } => { InstallJobKind::InstallPackToExistingInstance } + Self::UpdateSharedInstance { .. } => { + InstallJobKind::UpdateSharedInstance + } } } pub fn target(&self) -> InstallTarget { match self { Self::InstallExistingInstance { instance_id, .. } - | Self::InstallPackToExistingInstance { instance_id, .. } => { + | Self::InstallPackToExistingInstance { instance_id, .. } + | Self::UpdateSharedInstance { instance_id, .. } => { InstallTarget::ExistingInstance { instance_id: instance_id.clone(), } @@ -234,7 +288,8 @@ impl InstallRequest { pub fn cleanup(&self) -> InstallCleanup { match self { Self::InstallExistingInstance { instance_id, .. } - | Self::InstallPackToExistingInstance { instance_id, .. } => { + | Self::InstallPackToExistingInstance { instance_id, .. } + | Self::UpdateSharedInstance { instance_id, .. } => { InstallCleanup::RestoreExistingInstance { instance_id: instance_id.clone(), } @@ -249,10 +304,12 @@ impl InstallRequest { pub enum InstallJobKind { CreateInstance, CreateModpackInstance, + CreateSharedInstance, ImportInstance, DuplicateInstance, InstallExistingInstance, InstallPackToExistingInstance, + UpdateSharedInstance, } impl InstallJobKind { @@ -260,24 +317,28 @@ impl InstallJobKind { match self { Self::CreateInstance => "create_instance", Self::CreateModpackInstance => "create_modpack_instance", + Self::CreateSharedInstance => "create_shared_instance", Self::ImportInstance => "import_instance", Self::DuplicateInstance => "duplicate_instance", Self::InstallExistingInstance => "install_existing_instance", Self::InstallPackToExistingInstance => { "install_pack_to_existing_instance" } + Self::UpdateSharedInstance => "update_shared_instance", } } pub fn from_stored_str(value: &str) -> Self { match value { "create_modpack_instance" => Self::CreateModpackInstance, + "create_shared_instance" => Self::CreateSharedInstance, "import_instance" => Self::ImportInstance, "duplicate_instance" => Self::DuplicateInstance, "install_existing_instance" => Self::InstallExistingInstance, "install_pack_to_existing_instance" => { Self::InstallPackToExistingInstance } + "update_shared_instance" => Self::UpdateSharedInstance, _ => Self::CreateInstance, } } @@ -484,6 +545,8 @@ pub struct InstallErrorView { pub phase: Option, pub message: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub api: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option, @@ -513,6 +576,7 @@ impl InstallErrorView { code: code.to_string(), phase: Some(phase), message: error.to_string(), + reason: None, api: match error.raw.as_ref() { crate::ErrorKind::LabrinthError(error) => { Some(InstallApiErrorDetails { @@ -538,6 +602,7 @@ impl InstallErrorView { code: code.to_string(), phase: Some(phase), message: message.into(), + reason: None, api: None, context: None, } diff --git a/packages/app-lib/src/install/recovery.rs b/packages/app-lib/src/install/recovery.rs index e74feb323..db56f2e2b 100644 --- a/packages/app-lib/src/install/recovery.rs +++ b/packages/app-lib/src/install/recovery.rs @@ -7,7 +7,257 @@ use super::model::{ use super::store; use crate::event::InstancePayloadType; use crate::event::emit::emit_instance; -use crate::state::State; +use crate::state::instances::adapters::sqlite::{content_rows, instance_rows}; +use crate::state::{ + ContentEntry, ContentSetRemoteRef, ContentSetRemoteRefType, + ContentSetSyncProvider, ContentSetSyncState, InstanceFile, + InstanceMetadata, State, +}; +use async_walkdir::WalkDir; +use chrono::Utc; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +const SHARED_INSTANCE_ROLLBACK_FILE: &str = "rollback.json"; +const SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR: &str = "instance"; + +#[derive(Deserialize, Serialize)] +struct SharedInstanceUpdateRollback { + files: Vec, + entries: Vec, +} + +pub(super) async fn prepare_shared_instance_update_backup( + job_id: Uuid, + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let staging_dir = state + .directories + .metadata_dir() + .join("install_job_backups") + .join(job_id.to_string()); + if tokio::fs::try_exists(&staging_dir).await? { + crate::util::io::remove_dir_all(&staging_dir).await?; + } + crate::util::io::create_dir_all(&staging_dir).await?; + + let result = async { + let files = content_rows::get_instance_files( + &metadata.instance.id, + &state.pool, + ) + .await?; + let entries = content_rows::get_content_entries( + &metadata.applied_content_set.id, + &state.pool, + ) + .await?; + let snapshot = SharedInstanceUpdateRollback { files, entries }; + let instance_path = state + .directories + .instances_dir() + .join(&metadata.instance.path); + copy_directory( + &instance_path, + &staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR), + state, + ) + .await?; + crate::util::io::write( + staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE), + serde_json::to_vec(&snapshot)?, + ) + .await?; + + Ok::<(), crate::Error>(()) + } + .await; + + if result.is_err() { + let _ = crate::util::io::remove_dir_all(&staging_dir).await; + } + result?; + Ok(staging_dir) +} + +pub(super) async fn clear_staging_dir(job_state: &InstallJobState) { + let Some(staging_dir) = &job_state.paths.staging_dir else { + return; + }; + if let Err(error) = crate::util::io::remove_dir_all(staging_dir).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + path = %staging_dir.display(), + "Failed to remove install rollback backup: {error}" + ); + } +} + +async fn restore_shared_instance_update( + staging_dir: &Path, + rollback: &super::model::InstallRollbackState, + state: &State, +) -> crate::Result<()> { + let snapshot = serde_json::from_slice::( + &crate::util::io::read(staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE)) + .await?, + )?; + let instance_path = state + .directories + .instances_dir() + .join(&rollback.instance.instance.path); + if tokio::fs::try_exists(&instance_path).await? { + crate::util::io::remove_dir_all(&instance_path).await?; + } + copy_directory( + &staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR), + &instance_path, + state, + ) + .await?; + content_rows::restore_instance_content_snapshot( + &rollback.instance.instance.id, + &snapshot.files, + &snapshot.entries, + &state.pool, + ) + .await?; + restore_instance_metadata(&rollback.instance, state).await?; + + Ok(()) +} + +async fn restore_instance_metadata( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let content_set_id = metadata.applied_content_set.id.as_str(); + let mut tx = state.pool.begin().await?; + instance_rows::update_instance(&metadata.instance, &mut tx).await?; + content_rows::update_content_set(&metadata.applied_content_set, &mut tx) + .await?; + instance_rows::upsert_instance_link( + &metadata.instance.id, + &metadata.link, + &mut tx, + ) + .await?; + instance_rows::set_shared_instance_attachment( + &metadata.instance.id, + metadata.shared_instance.as_ref(), + &mut tx, + ) + .await?; + instance_rows::replace_instance_groups( + &metadata.instance.id, + &metadata.groups, + &mut tx, + ) + .await?; + instance_rows::upsert_instance_launch_overrides( + &metadata.launch_overrides, + &mut tx, + ) + .await?; + content_rows::delete_content_set_remote_ref( + content_set_id, + ContentSetRemoteRefType::SharedContentSet, + &mut tx, + ) + .await?; + content_rows::delete_content_set_sync_state(content_set_id, &mut tx) + .await?; + if let Some(attachment) = &metadata.shared_instance { + content_rows::upsert_content_set_remote_ref( + &ContentSetRemoteRef { + content_set_id: content_set_id.to_string(), + ref_type: ContentSetRemoteRefType::SharedContentSet, + ref_id: attachment.id.clone(), + }, + &mut tx, + ) + .await?; + content_rows::upsert_content_set_sync_state( + &ContentSetSyncState { + content_set_id: content_set_id.to_string(), + provider: ContentSetSyncProvider::SharedInstance, + applied_update_id: attachment + .applied_version + .map(|value| value.to_string()), + latest_available_update_id: attachment + .latest_version + .map(|value| value.to_string()), + checked_at: Some(Utc::now()), + status: attachment.status, + }, + &mut tx, + ) + .await?; + } + tx.commit().await?; + + Ok(()) +} + +async fn copy_directory( + source: &Path, + target: &Path, + state: &State, +) -> crate::Result<()> { + crate::util::io::create_dir_all(target).await?; + let mut walker = WalkDir::new(source); + while let Some(entry) = walker.next().await { + let entry = entry.map_err(|error| { + crate::ErrorKind::FSError(format!( + "Failed to read instance backup path: {error}" + )) + })?; + let entry_path = entry.path(); + let relative_path = entry_path.strip_prefix(source)?; + let target_path = target.join(relative_path); + let file_type = entry.file_type().await?; + if file_type.is_dir() { + crate::util::io::create_dir_all(&target_path).await?; + } else if file_type.is_file() { + crate::util::fetch::copy( + &entry_path, + &target_path, + &state.io_semaphore, + ) + .await?; + } else if file_type.is_symlink() { + copy_symlink(&entry_path, &target_path).await?; + } + } + + Ok(()) +} + +async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> { + if let Some(parent) = target.parent() { + crate::util::io::create_dir_all(parent).await?; + } + let link_target = tokio::fs::read_link(source).await?; + + #[cfg(unix)] + tokio::fs::symlink(link_target, target).await?; + + #[cfg(windows)] + { + let metadata = tokio::fs::metadata(source).await?; + if metadata.is_dir() { + tokio::fs::symlink_dir(link_target, target).await?; + } else { + tokio::fs::symlink_file(link_target, target).await?; + } + } + + Ok(()) +} pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> { let jobs = store::list_interrupted_candidates(state).await?; @@ -61,6 +311,9 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> { state, ) .await?; + if job.state.rollback_error.is_none() { + clear_staging_dir(&job.state).await; + } emit_install_job(&record.snapshot()).await?; } @@ -96,6 +349,15 @@ fn display_from_request(state: &InstallJobState) -> Option { .. } => None, }, + InstallRequest::CreateSharedInstance { data } => { + Some(InstallJobDisplay { + title: data.name.clone(), + icon: data + .modpack + .as_ref() + .and_then(|modpack| modpack.icon_url.clone()), + }) + } InstallRequest::ImportInstance { instance_folder, .. } => Some(InstallJobDisplay { @@ -104,7 +366,8 @@ fn display_from_request(state: &InstallJobState) -> Option { }), InstallRequest::DuplicateInstance { .. } | InstallRequest::InstallExistingInstance { .. } - | InstallRequest::InstallPackToExistingInstance { .. } => { + | InstallRequest::InstallPackToExistingInstance { .. } + | InstallRequest::UpdateSharedInstance { .. } => { state.rollback.as_ref().map(|rollback| InstallJobDisplay { title: rollback.instance.instance.name.clone(), icon: rollback.instance.instance.icon_path.clone(), @@ -128,12 +391,25 @@ pub async fn apply_cleanup( } InstallCleanup::RestoreExistingInstance { instance_id } => { if let Some(rollback) = &job_state.rollback { - crate::state::instances::commands::set_instance_install_stage( - instance_id, - rollback.install_stage, - &state.pool, - ) - .await?; + if matches!( + &job_state.request, + InstallRequest::UpdateSharedInstance { .. } + ) && let Some(staging_dir) = &job_state.paths.staging_dir + { + restore_shared_instance_update( + staging_dir, + rollback, + state, + ) + .await?; + } else { + crate::state::instances::commands::set_instance_install_stage( + instance_id, + rollback.install_stage, + &state.pool, + ) + .await?; + } emit_instance(instance_id, InstancePayloadType::Edited).await?; } } diff --git a/packages/app-lib/src/install/runner.rs b/packages/app-lib/src/install/runner.rs index 36515a053..ac08df288 100644 --- a/packages/app-lib/src/install/runner.rs +++ b/packages/app-lib/src/install/runner.rs @@ -3,7 +3,13 @@ use super::model::{ InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay, InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus, InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit, - InstallRequest, InstallRollbackState, InstallTarget, + InstallProgress, InstallRequest, InstallRollbackState, InstallTarget, + SharedInstanceInstallData, +}; +use super::shared_instance::{ + apply_shared_instance_content, apply_shared_instance_update, + attach_pending_shared_instance, finalize_shared_instance_attachment, + shared_instance_link, shared_instance_pack_location, }; use super::{diagnostics, recovery, store}; use crate::ErrorKind; @@ -53,6 +59,19 @@ pub async fn create_modpack_instance( .await } +pub async fn create_shared_instance( + data: SharedInstanceInstallData, +) -> crate::Result { + start(InstallRequest::CreateSharedInstance { data }).await +} + +pub async fn update_shared_instance( + instance_id: String, + data: SharedInstanceInstallData, +) -> crate::Result { + start(InstallRequest::UpdateSharedInstance { instance_id, data }).await +} + pub async fn import_instance( launcher_type: crate::api::pack::import::ImportLauncherType, base_path: PathBuf, @@ -129,9 +148,15 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result { .into()); } + if job.state.rollback_error.is_some() { + recovery::apply_cleanup(&job.state, &state).await?; + recovery::clear_staging_dir(&job.state).await; + } + job.state.target = job.state.request.target(); job.state.cleanup = job.state.request.cleanup(); job.state.rollback = None; + job.state.paths.staging_dir = None; job.state.error = None; job.state.rollback_error = None; job.state.context = None; @@ -150,6 +175,7 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result { &state, ) .await?; + lock_existing_instance_if_needed(&job.state, &state).await?; emit_install_job(&record.snapshot()).await?; spawn_job(job_id); @@ -204,6 +230,9 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result { &state, ) .await?; + if job.state.rollback_error.is_none() { + recovery::clear_staging_dir(&job.state).await; + } emit_install_job(&record.snapshot()).await?; Ok(record.snapshot()) @@ -221,6 +250,7 @@ async fn start(request: InstallRequest) -> crate::Result { prepare_initial_instance(&mut job_state, &state).await?; let record = store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?; + lock_existing_instance_if_needed(&job_state, &state).await?; emit_install_job(&record.snapshot()).await?; spawn_job(id); Ok(record.snapshot()) @@ -296,6 +326,54 @@ async fn prepare_initial_instance( ); set_instance_id(job_state, metadata.instance.id); } + InstallRequest::CreateSharedInstance { data } => { + let shared_link = shared_instance_link(data.modpack.as_ref()); + let (game_version, loader, loader_version, icon_path) = + if let Some(modpack) = data.modpack.clone() { + let preview = get_instance_from_pack( + shared_instance_pack_location(modpack), + ) + .await?; + ( + preview.game_version, + preview.modloader, + preview.loader_version, + data.instance_icon_url + .clone() + .or_else(|| { + preview.icon.as_ref().map(|path| { + path.to_string_lossy().to_string() + }) + }) + .or_else(|| preview.icon_url.clone()), + ) + } else { + ( + data.game_version.clone(), + data.loader, + data.loader_version.clone(), + data.instance_icon_url.clone(), + ) + }; + let metadata = crate::api::instance::create( + data.name.clone(), + game_version, + loader, + loader_version, + icon_path, + shared_link, + ) + .await?; + set_display( + job_state, + metadata.instance.name, + metadata.instance.icon_path, + ); + let instance_id = metadata.instance.id; + attach_pending_shared_instance(&instance_id, &data, state).await?; + emit_instance(&instance_id, InstancePayloadType::Edited).await?; + set_instance_id(job_state, instance_id); + } InstallRequest::ImportInstance { instance_folder, .. } => { @@ -343,7 +421,8 @@ async fn prepare_initial_instance( InstallRequest::InstallExistingInstance { instance_id, .. } | InstallRequest::InstallPackToExistingInstance { instance_id, .. - } => { + } + | InstallRequest::UpdateSharedInstance { instance_id, .. } => { prepare_existing_rollback(job_state, state, &instance_id).await?; } } @@ -353,7 +432,7 @@ async fn prepare_initial_instance( fn spawn_job(job_id: Uuid) { tokio::spawn(async move { - if let Err(error) = run_job(job_id).await { + if let Err(error) = Box::pin(run_job(job_id)).await { tracing::error!( "Install job {job_id} failed to update state: {error}" ); @@ -387,16 +466,23 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> { .await?; emit_install_job(&record.snapshot()).await?; - let result = run_request(job_id, &mut job_state, &state).await; + let result = Box::pin(run_request(job_id, &mut job_state, &state)).await; if let Ok(record) = store::get_required(job_id, &state).await { job_state = record.state; } - match result { + let result = match result { Ok(instance_id) => { if let Some(instance_id) = instance_id { set_instance_id(&mut job_state, instance_id); } + finalize_existing_instance_success(&job_state, &state).await + } + Err(error) => Err(error), + }; + + match result { + Ok(()) => { job_state.record_event(InstallJobEventKind::JobSucceeded { instance_id: current_instance_id(&job_state), }); @@ -413,6 +499,7 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> { &state, ) .await?; + recovery::clear_staging_dir(&job_state).await; emit_install_job(&record.snapshot()).await?; } Err(error) => { @@ -459,6 +546,9 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> { &state, ) .await?; + if job_state.rollback_error.is_none() { + recovery::clear_staging_dir(&job_state).await; + } emit_install_job(&record.snapshot()).await?; return Err(error); } @@ -541,17 +631,39 @@ async fn run_request( modpack_details(&location), ) .await?; - install_pack( + Box::pin(install_pack( job_id, job_state, location, instance_id.clone(), DownloadReason::Modpack, - ) + )) .await?; apply_post_install_edit(&instance_id, post_install_edit).await?; Ok(Some(instance_id)) } + InstallRequest::CreateSharedInstance { data } => { + let Some(instance_id) = current_instance_id(job_state) else { + return Err(crate::ErrorKind::InputError( + "Install job is missing its instance id".to_string(), + ) + .into()); + }; + Box::pin(apply_shared_instance_content( + job_id, + job_state, + state, + &instance_id, + &data, + )) + .await?; + + finalize_shared_instance_attachment(&instance_id, &data, state) + .await?; + emit_instance(&instance_id, InstancePayloadType::Edited).await?; + + Ok(Some(instance_id)) + } InstallRequest::ImportInstance { launcher_type, base_path, @@ -629,6 +741,7 @@ async fn run_request( } InstallRequest::InstallExistingInstance { instance_id, force } => { prepare_existing_rollback(job_state, state, &instance_id).await?; + lock_existing_instance(&instance_id, state).await?; update_progress( job_id, job_state, @@ -660,6 +773,7 @@ async fn run_request( post_install_edit, } => { prepare_existing_rollback(job_state, state, &instance_id).await?; + lock_existing_instance(&instance_id, state).await?; let disabled_project_ids = remove_existing_pack_content( job_id, job_state, @@ -667,13 +781,13 @@ async fn run_request( &instance_id, ) .await?; - install_pack( + Box::pin(install_pack( job_id, job_state, location, instance_id.clone(), DownloadReason::Modpack, - ) + )) .await?; restore_disabled_projects( &instance_id, @@ -684,6 +798,49 @@ async fn run_request( apply_post_install_edit(&instance_id, post_install_edit).await?; Ok(Some(instance_id)) } + InstallRequest::UpdateSharedInstance { instance_id, data } => { + prepare_existing_rollback(job_state, state, &instance_id).await?; + lock_existing_instance(&instance_id, state).await?; + let rollback_instance = job_state + .rollback + .as_ref() + .map(|rollback| rollback.instance.clone()) + .ok_or_else(|| { + crate::ErrorKind::OtherError( + "Shared instance update rollback state is missing" + .to_string(), + ) + })?; + let staging_dir = recovery::prepare_shared_instance_update_backup( + job_id, + &rollback_instance, + state, + ) + .await?; + job_state.paths.staging_dir = Some(staging_dir); + let record = store::update_state(job_id, job_state, state).await?; + emit_install_job(&record.snapshot()).await?; + let disabled_project_ids = + disabled_project_ids(&instance_id, state).await?; + Box::pin(apply_shared_instance_update( + job_id, + job_state, + state, + &instance_id, + &data, + )) + .await?; + restore_disabled_projects( + &instance_id, + disabled_project_ids, + state, + ) + .await?; + finalize_shared_instance_attachment(&instance_id, &data, state) + .await?; + emit_instance(&instance_id, InstancePayloadType::Edited).await?; + Ok(Some(instance_id)) + } } } @@ -714,6 +871,20 @@ async fn apply_post_install_edit( Ok(()) } +async fn disabled_project_ids( + instance_id: &str, + state: &State, +) -> crate::Result> { + Ok(crate::state::instances::commands::list_project_files( + instance_id, + state, + ) + .await? + .into_iter() + .filter_map(|file| (!file.enabled).then_some(file.project_id?)) + .collect()) +} + async fn remove_existing_pack_content( job_id: Uuid, job_state: &InstallJobState, @@ -873,7 +1044,7 @@ async fn restore_disabled_projects( Ok(()) } -async fn install_pack( +pub(super) async fn install_pack( job_id: Uuid, job_state: &mut InstallJobState, location: CreatePackLocation, @@ -927,12 +1098,12 @@ async fn install_pack( } }; - install_zipped_mrpack_files_with_reporter( + Box::pin(install_zipped_mrpack_files_with_reporter( create_pack, false, reason, reporter, - ) + )) .await?; Ok(()) @@ -954,6 +1125,12 @@ async fn prepare_existing_rollback( "Unknown instance {instance_id}" )) })?; + if instance.quarantined { + return Err(crate::ErrorKind::InputError( + "Content in quarantined instances cannot be changed.".to_string(), + ) + .into()); + } let install_stage = instance.instance.install_stage; set_display( job_state, @@ -968,6 +1145,26 @@ async fn prepare_existing_rollback( instance_id: instance_id.to_string(), }; + Ok(()) +} + +async fn lock_existing_instance_if_needed( + job_state: &InstallJobState, + state: &State, +) -> crate::Result<()> { + if let InstallCleanup::RestoreExistingInstance { instance_id } = + &job_state.cleanup + { + lock_existing_instance(instance_id, state).await?; + } + + Ok(()) +} + +async fn lock_existing_instance( + instance_id: &str, + state: &State, +) -> crate::Result<()> { crate::state::instances::commands::set_instance_install_stage( instance_id, InstanceInstallStage::MinecraftInstalling, @@ -979,7 +1176,26 @@ async fn prepare_existing_rollback( Ok(()) } -async fn update_progress( +async fn finalize_existing_instance_success( + job_state: &InstallJobState, + state: &State, +) -> crate::Result<()> { + if let InstallCleanup::RestoreExistingInstance { instance_id } = + &job_state.cleanup + { + crate::state::instances::commands::set_instance_install_stage( + instance_id, + InstanceInstallStage::Installed, + &state.pool, + ) + .await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + } + + Ok(()) +} + +pub(super) async fn update_progress( job_id: Uuid, job_state: &mut InstallJobState, state: &State, @@ -992,6 +1208,25 @@ async fn update_progress( Ok(()) } +pub(super) async fn update_content_progress( + job_id: Uuid, + job_state: &mut InstallJobState, + state: &State, + current: u64, + total: u64, +) -> crate::Result<()> { + job_state.progress.phase = InstallPhaseId::DownloadingContent; + job_state.progress.progress = Some(InstallProgress { + current, + total, + secondary: None, + }); + job_state.progress.details = InstallPhaseDetails::Empty; + let record = store::update_state(job_id, job_state, state).await?; + emit_install_job(&record.snapshot()).await?; + Ok(()) +} + fn set_instance_id(job_state: &mut InstallJobState, instance_id: String) { job_state.target = match &job_state.target { InstallTarget::ExistingInstance { .. } => { @@ -1036,12 +1271,16 @@ fn install_error_view( error: &crate::Error, context: Option, ) -> InstallErrorView { - InstallErrorView::from_error( + let mut view = InstallErrorView::from_error( install_error_code(phase, error), phase, error, context, - ) + ); + if let ErrorKind::SharedInstanceUnavailable(reason) = error.raw.as_ref() { + view.reason = Some(*reason); + } + view } fn install_error_code( @@ -1051,6 +1290,9 @@ fn install_error_code( use InstallPhaseId::*; match error.raw.as_ref() { + ErrorKind::SharedInstanceUnavailable(_) => { + "shared_instance_unavailable" + } ErrorKind::InputError(_) => match phase { PreparingInstance | Finalizing => "instance_error", ResolvingPack | DownloadingPackFile | ReadingPackManifest => { @@ -1079,6 +1321,8 @@ fn install_error_code( }, ErrorKind::FetchError(_) | ErrorKind::ApiIsDownError(_) + | ErrorKind::WSError(_) + | ErrorKind::WSClosedError(_) | ErrorKind::Ratelimited { .. } => "network_error", ErrorKind::Any(_) if matches!( @@ -1123,7 +1367,9 @@ fn current_instance_id(job_state: &InstallJobState) -> Option { } } -fn modpack_details(location: &CreatePackLocation) -> InstallPhaseDetails { +pub(super) fn modpack_details( + location: &CreatePackLocation, +) -> InstallPhaseDetails { match location { CreatePackLocation::FromVersionId { project_id, diff --git a/packages/app-lib/src/install/shared_instance.rs b/packages/app-lib/src/install/shared_instance.rs new file mode 100644 index 000000000..36cbbc157 --- /dev/null +++ b/packages/app-lib/src/install/shared_instance.rs @@ -0,0 +1,1016 @@ +use super::events::InstallProgressReporter; +use super::model::{ + InstallJobState, InstallPhaseDetails, InstallPhaseId, + SharedInstanceExternalFileData, SharedInstanceInstallData, + SharedInstanceInstallModpack, +}; +use super::runner::{ + install_pack, modpack_details, update_content_progress, update_progress, +}; +use crate::api::instance::{ + CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS, + CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES, + read_bounded_config_bundle_entry, +}; +use crate::api::pack::install_from::CreatePackLocation; +use crate::state::instances::adapters::sqlite::content_rows; +use crate::state::{ + CachedEntry, ContentSetSyncStatus, ContentSourceKind, InstanceLink, + ProjectType, SharedInstanceAttachmentInput, SharedInstanceRole, State, +}; +use crate::util::fetch::{DownloadReason, REQWEST_CLIENT}; +use futures::StreamExt; +use path_util::SafeRelativeUtf8UnixPathBuf; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use uuid::Uuid; + +const MAX_SHARED_INSTANCE_EXTERNAL_FILE_SIZE: u64 = 500 * 1024 * 1024; +const MAX_SHARED_INSTANCE_INITIAL_BUFFER_SIZE: u64 = 8 * 1024 * 1024; + +pub(super) async fn attach_pending_shared_instance( + instance_id: &str, + data: &SharedInstanceInstallData, + state: &State, +) -> crate::Result<()> { + crate::state::attach_shared_instance( + instance_id, + SharedInstanceAttachmentInput { + id: data.shared_instance_id.clone(), + role: SharedInstanceRole::Member, + manager_id: data.manager_id.clone(), + server_manager_name: data.server_manager_name.clone(), + server_manager_icon_url: data.server_manager_icon_url.clone(), + linked_user_id: data.linked_user_id.clone(), + status: ContentSetSyncStatus::NotReady, + applied_version: None, + latest_version: Some(data.version), + }, + &state.pool, + ) + .await +} + +pub(super) async fn finalize_shared_instance_attachment( + instance_id: &str, + data: &SharedInstanceInstallData, + state: &State, +) -> crate::Result<()> { + crate::state::attach_shared_instance( + instance_id, + SharedInstanceAttachmentInput { + id: data.shared_instance_id.clone(), + role: SharedInstanceRole::Member, + manager_id: data.manager_id.clone(), + server_manager_name: data.server_manager_name.clone(), + server_manager_icon_url: data.server_manager_icon_url.clone(), + linked_user_id: data.linked_user_id.clone(), + status: ContentSetSyncStatus::UpToDate, + applied_version: Some(data.version), + latest_version: Some(data.version), + }, + &state.pool, + ) + .await +} + +#[derive(Clone, Debug)] +struct CurrentSharedInstanceProject { + project_id: String, + version_id: String, + relative_path: String, +} + +#[derive(Clone, Debug)] +struct CurrentSharedInstanceExternalFile { + relative_path: String, +} + +type SharedInstanceExternalFileKey = (ProjectType, String); + +#[derive(Default)] +struct CurrentSharedInstanceContent { + projects: HashMap, + external_files: HashMap< + SharedInstanceExternalFileKey, + CurrentSharedInstanceExternalFile, + >, +} + +#[derive(Clone, Debug)] +struct DesiredSharedInstanceProject { + project_id: String, + version_id: String, +} + +#[derive(Clone, Debug)] +struct DesiredSharedInstanceExternalFile { + file: SharedInstanceExternalFileData, + file_type: ProjectType, +} + +#[derive(Default)] +struct DesiredSharedInstanceContent { + projects: HashMap, + external_files: HashMap< + SharedInstanceExternalFileKey, + DesiredSharedInstanceExternalFile, + >, + config_bundle: Option, +} + +struct SharedInstanceProjectUpdate { + current: CurrentSharedInstanceProject, + desired: DesiredSharedInstanceProject, +} + +#[derive(Default)] +struct SharedInstanceApplyPlan { + configuration_changed: bool, + project_removals: Vec, + project_updates: Vec, + project_additions: Vec, + external_removals: Vec, + external_updates: Vec, + external_additions: Vec, + config_bundle: Option, +} + +impl SharedInstanceApplyPlan { + async fn build( + metadata: &crate::state::InstanceMetadata, + data: &SharedInstanceInstallData, + state: &State, + ) -> crate::Result { + if shared_instance_update_requires_full_apply(metadata, data) { + return Ok(Self { + configuration_changed: true, + ..Default::default() + }); + } + + let current = current_shared_instance_content(metadata, state).await?; + let desired = desired_shared_instance_content(data, state).await?; + let project_removals = current + .projects + .values() + .filter(|current| { + !desired.projects.contains_key(¤t.project_id) + }) + .cloned() + .collect(); + let external_removals = current + .external_files + .iter() + .filter(|(key, _)| !desired.external_files.contains_key(*key)) + .map(|(_, current)| current.clone()) + .collect(); + let mut plan = Self { + project_removals, + external_removals, + config_bundle: desired.config_bundle, + ..Default::default() + }; + + for desired in desired.projects.into_values() { + match current.projects.get(&desired.project_id) { + Some(current) if current.version_id != desired.version_id => { + plan.project_updates.push(SharedInstanceProjectUpdate { + current: current.clone(), + desired, + }); + } + None => plan.project_additions.push(desired), + Some(_) => {} + } + } + + for desired in desired.external_files.into_values() { + let key = (desired.file_type, desired.file.file_name.clone()); + match current.external_files.get(&key) { + Some(_) => { + plan.external_updates.push(desired); + } + _ => plan.external_additions.push(desired), + } + } + + Ok(plan) + } + + fn content_change_count(&self) -> u64 { + (self.project_updates.len() + + self.project_additions.len() + + self.external_updates.len() + + self.external_additions.len() + + usize::from(self.config_bundle.is_some())) as u64 + } +} + +pub(super) async fn apply_shared_instance_update( + job_id: Uuid, + job_state: &mut InstallJobState, + state: &State, + instance_id: &str, + data: &SharedInstanceInstallData, +) -> crate::Result<()> { + let metadata = crate::state::instances::commands::get_instance_metadata( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let plan = SharedInstanceApplyPlan::build(&metadata, data, state).await?; + + if plan.configuration_changed { + remove_existing_shared_instance_content(instance_id, state).await?; + Box::pin(apply_shared_instance_content( + job_id, + job_state, + state, + instance_id, + data, + )) + .await?; + return Ok(()); + } + + update_progress( + job_id, + job_state, + state, + InstallPhaseId::PreparingInstance, + InstallPhaseDetails::Instance { + name: data.name.clone(), + }, + ) + .await?; + + let content_change_count = plan.content_change_count(); + if content_change_count > 0 { + update_content_progress( + job_id, + job_state, + state, + 0, + content_change_count, + ) + .await?; + } + + for project in plan.project_removals { + crate::state::instances::commands::remove_project( + instance_id, + &project.relative_path, + state, + ) + .await?; + } + + for file in plan.external_removals { + crate::state::instances::commands::remove_project( + instance_id, + &file.relative_path, + state, + ) + .await?; + } + + let mut completed_content_changes = 0; + for update in plan.project_updates { + let new_path = + crate::state::instances::commands::add_project_from_version( + instance_id, + &update.desired.version_id, + DownloadReason::Update, + Some(update.current.version_id), + ContentSourceKind::SharedInstance, + state, + ) + .await?; + + if update.current.relative_path != new_path { + crate::state::instances::commands::remove_project( + instance_id, + &update.current.relative_path, + state, + ) + .await?; + } + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + for project in plan.project_additions { + crate::state::instances::commands::add_project_from_version( + instance_id, + &project.version_id, + DownloadReason::Standalone, + None, + ContentSourceKind::SharedInstance, + state, + ) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + for file in plan + .external_updates + .into_iter() + .chain(plan.external_additions) + { + install_shared_instance_external_file(instance_id, &file.file, state) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + if let Some(config_bundle) = plan.config_bundle { + install_shared_instance_external_file( + instance_id, + &config_bundle, + state, + ) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + crate::api::instance::edit( + instance_id, + crate::state::EditInstance { + name: Some(data.name.clone()), + link: Some(shared_instance_link(data.modpack.as_ref())), + ..Default::default() + }, + ) + .await?; + + Ok(()) +} + +fn shared_instance_update_requires_full_apply( + metadata: &crate::state::InstanceMetadata, + data: &SharedInstanceInstallData, +) -> bool { + let (current_modpack_project_id, current_modpack_version_id) = + match &metadata.link { + InstanceLink::SharedInstance { + modpack_project_id, + modpack_version_id, + } => (modpack_project_id.as_deref(), modpack_version_id.as_deref()), + _ => return true, + }; + let next_modpack_project_id = data + .modpack + .as_ref() + .map(|modpack| modpack.project_id.as_str()); + let next_modpack_version_id = data + .modpack + .as_ref() + .map(|modpack| modpack.version_id.as_str()); + + current_modpack_project_id != next_modpack_project_id + || current_modpack_version_id != next_modpack_version_id + || metadata.applied_content_set.game_version != data.game_version + || metadata.applied_content_set.loader != data.loader + || metadata.applied_content_set.loader_version != data.loader_version +} + +async fn current_shared_instance_content( + metadata: &crate::state::InstanceMetadata, + state: &State, +) -> crate::Result { + let entries = content_rows::get_content_entries( + &metadata.applied_content_set.id, + &state.pool, + ) + .await?; + let files = + content_rows::get_instance_files(&metadata.instance.id, &state.pool) + .await? + .into_iter() + .map(|file| (file.id.clone(), file)) + .collect::>(); + let version_ids_without_project = entries + .iter() + .filter(|entry| entry.source_kind == ContentSourceKind::SharedInstance) + .filter(|entry| entry.project_id.is_none()) + .filter_map(|entry| entry.version_id.clone()) + .collect::>(); + let versions_by_id = + shared_instance_versions_by_id(&version_ids_without_project, state) + .await?; + let mut content = CurrentSharedInstanceContent::default(); + + for entry in entries { + if entry.source_kind != ContentSourceKind::SharedInstance { + continue; + } + + let Some(file_id) = entry.file_id.as_ref() else { + continue; + }; + let Some(file) = files.get(file_id) else { + continue; + }; + + if let Some(version_id) = entry.version_id.clone() { + let Some(project_id) = entry.project_id.clone().or_else(|| { + versions_by_id + .get(&version_id) + .map(|version| version.project_id.clone()) + }) else { + continue; + }; + + content.projects.insert( + project_id.clone(), + CurrentSharedInstanceProject { + project_id, + version_id, + relative_path: file.relative_path.clone(), + }, + ); + } else { + content.external_files.insert( + (entry.project_type, file.file_name.clone()), + CurrentSharedInstanceExternalFile { + relative_path: file.relative_path.clone(), + }, + ); + } + } + + Ok(content) +} + +async fn desired_shared_instance_content( + data: &SharedInstanceInstallData, + state: &State, +) -> crate::Result { + let versions_by_id = + shared_instance_versions_by_id(&data.modrinth_ids, state).await?; + let mut content = DesiredSharedInstanceContent::default(); + + for version_id in &data.modrinth_ids { + let version = versions_by_id.get(version_id).ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Shared instance version {version_id} was not found" + )) + })?; + content.projects.insert( + version.project_id.clone(), + DesiredSharedInstanceProject { + project_id: version.project_id.clone(), + version_id: version.id.clone(), + }, + ); + } + + for file in &data.external_files { + if file.file_type == CONFIG_BUNDLE_FILE_TYPE { + if CONFIG_SYNC_ENABLED { + content.config_bundle = Some(file.clone()); + } + continue; + } + let file_type = + ProjectType::from_name(&file.file_type).ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown shared instance file type {}", + file.file_type + )) + })?; + content.external_files.insert( + (file_type, file.file_name.clone()), + DesiredSharedInstanceExternalFile { + file: file.clone(), + file_type, + }, + ); + } + + Ok(content) +} + +async fn shared_instance_versions_by_id( + version_ids: &[String], + state: &State, +) -> crate::Result> { + if version_ids.is_empty() { + return Ok(HashMap::new()); + } + + let mut ids = version_ids.iter().map(String::as_str).collect::>(); + ids.sort_unstable(); + ids.dedup(); + let versions = CachedEntry::get_version_many( + &ids, + None, + &state.pool, + &state.api_semaphore, + ) + .await?; + let versions_by_id = versions + .into_iter() + .map(|version| (version.id.clone(), version)) + .collect::>(); + + for id in ids { + if !versions_by_id.contains_key(id) { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance version {id} was not found" + )) + .into()); + } + } + + Ok(versions_by_id) +} + +pub(super) async fn apply_shared_instance_content( + job_id: Uuid, + job_state: &mut InstallJobState, + state: &State, + instance_id: &str, + data: &SharedInstanceInstallData, +) -> crate::Result<()> { + update_progress( + job_id, + job_state, + state, + InstallPhaseId::PreparingInstance, + InstallPhaseDetails::Instance { + name: data.name.clone(), + }, + ) + .await?; + + if let Some(modpack) = data.modpack.clone() { + let location = shared_instance_pack_location(modpack); + update_progress( + job_id, + job_state, + state, + InstallPhaseId::ResolvingPack, + modpack_details(&location), + ) + .await?; + Box::pin(install_pack( + job_id, + job_state, + location, + instance_id.to_string(), + DownloadReason::Modpack, + )) + .await?; + } else { + crate::api::instance::edit( + instance_id, + crate::state::EditInstance { + content_set_patch: Some(crate::state::AppliedContentSetPatch { + source_kind: Some(ContentSourceKind::SharedInstance), + game_version: Some(data.game_version.clone()), + protocol_version: Some(None), + loader: Some(data.loader), + loader_version: Some(data.loader_version.clone()), + }), + ..Default::default() + }, + ) + .await?; + update_progress( + job_id, + job_state, + state, + InstallPhaseId::DownloadingMinecraft, + InstallPhaseDetails::Minecraft { + game_version: data.game_version.clone(), + loader: data.loader, + }, + ) + .await?; + let context = + crate::state::instances::commands::get_instance_launch_context( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + crate::launcher::install_minecraft_with_reporter( + &context, + false, + Some(InstallProgressReporter::new(job_id, job_state.clone())), + ) + .await?; + } + + if !data.modrinth_ids.is_empty() || !data.external_files.is_empty() { + let content_change_count = + data.modrinth_ids.len() as u64 + data.external_files.len() as u64; + update_content_progress( + job_id, + job_state, + state, + 0, + content_change_count, + ) + .await?; + let mut completed_content_changes = 0; + for version_id in &data.modrinth_ids { + crate::state::instances::commands::add_project_from_version( + instance_id, + version_id, + DownloadReason::Standalone, + None, + ContentSourceKind::SharedInstance, + state, + ) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + for file in &data.external_files { + install_shared_instance_external_file(instance_id, file, state) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + } + + crate::api::instance::edit( + instance_id, + crate::state::EditInstance { + name: Some(data.name.clone()), + link: Some(shared_instance_link(data.modpack.as_ref())), + ..Default::default() + }, + ) + .await?; + + Ok(()) +} + +pub(super) async fn remove_existing_shared_instance_content( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + let _content_lock = state.lock_instance_content(instance_id).await; + let metadata = crate::state::instances::commands::get_instance_metadata( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let entries = content_rows::get_content_entries( + &metadata.applied_content_set.id, + &state.pool, + ) + .await?; + let files = content_rows::get_instance_files(instance_id, &state.pool) + .await? + .into_iter() + .map(|file| (file.id.clone(), file)) + .collect::>(); + let base = state + .directories + .instances_dir() + .join(&metadata.instance.path); + + let mut removed_file_ids = HashSet::new(); + for entry in entries { + if !entry.source_kind.is_shared_instance_managed() { + continue; + } + + let Some(file_id) = entry.file_id else { + continue; + }; + if !removed_file_ids.insert(file_id.clone()) { + continue; + } + + let Some(file) = files.get(&file_id) else { + continue; + }; + crate::util::io::remove_file(base.join(&file.relative_path)).await?; + let mut tx = state.pool.begin().await?; + content_rows::remove_content_entries_for_file( + &metadata.applied_content_set.id, + &file.id, + &mut tx, + ) + .await?; + content_rows::remove_instance_file_by_relative_path( + instance_id, + &file.relative_path, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + Ok(()) +} + +pub(super) fn shared_instance_pack_location( + modpack: SharedInstanceInstallModpack, +) -> CreatePackLocation { + CreatePackLocation::FromVersionId { + project_id: modpack.project_id, + version_id: modpack.version_id, + title: modpack.title, + icon_url: modpack.icon_url, + } +} + +pub(super) fn shared_instance_link( + modpack: Option<&SharedInstanceInstallModpack>, +) -> InstanceLink { + InstanceLink::SharedInstance { + modpack_project_id: modpack.map(|modpack| modpack.project_id.clone()), + modpack_version_id: modpack.map(|modpack| modpack.version_id.clone()), + } +} + +async fn install_shared_instance_external_file( + instance_id: &str, + file: &SharedInstanceExternalFileData, + state: &State, +) -> crate::Result<()> { + if file.file_type == CONFIG_BUNDLE_FILE_TYPE && !CONFIG_SYNC_ENABLED { + return Ok(()); + } + + validate_shared_instance_external_file_name(&file.file_name)?; + + if file.file_size > MAX_SHARED_INSTANCE_EXTERNAL_FILE_SIZE { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance external file {} exceeds the maximum size", + file.file_name + )) + .into()); + } + + let response = REQWEST_CLIENT.get(&file.url).send().await?; + + if !response.status().is_success() { + return Err(crate::ErrorKind::OtherError(format!( + "Shared instance external file download failed with status {}", + response.status() + )) + .into()); + } + + if response + .content_length() + .is_some_and(|content_length| content_length != file.file_size) + { + return Err(crate::ErrorKind::OtherError(format!( + "Shared instance external file {} has an unexpected size", + file.file_name + )) + .into()); + } + + let mut stream = response.bytes_stream(); + let initial_capacity = usize::try_from( + file.file_size.min(MAX_SHARED_INSTANCE_INITIAL_BUFFER_SIZE), + ) + .map_err(|_| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {} is too large", + file.file_name + )) + })?; + let mut bytes = Vec::with_capacity(initial_capacity); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + let downloaded_size = (bytes.len() as u64) + .checked_add(chunk.len() as u64) + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {} is too large", + file.file_name + )) + })?; + if downloaded_size > file.file_size { + return Err(crate::ErrorKind::OtherError(format!( + "Shared instance external file {} has an unexpected size", + file.file_name + )) + .into()); + } + bytes.extend_from_slice(&chunk); + } + + if bytes.len() as u64 != file.file_size { + return Err(crate::ErrorKind::OtherError(format!( + "Shared instance external file {} has an unexpected size", + file.file_name + )) + .into()); + } + let bytes = bytes::Bytes::from(bytes); + + if file.file_type == CONFIG_BUNDLE_FILE_TYPE { + return install_shared_instance_config_bundle( + instance_id, + bytes, + state, + ) + .await; + } + + let project_type = + ProjectType::from_name(&file.file_type).ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown shared instance file type {}", + file.file_type + )) + })?; + + crate::state::instances::commands::add_project_bytes( + instance_id, + &file.file_name, + bytes, + None, + Some(project_type), + ContentSourceKind::SharedInstance, + None, + None, + state, + ) + .await?; + + Ok(()) +} + +fn validate_shared_instance_external_file_name( + file_name: &str, +) -> crate::Result<()> { + let path = SafeRelativeUtf8UnixPathBuf::try_from(file_name.to_string()) + .map_err(|_| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {file_name} has an invalid file name" + )) + })?; + if file_name == "." + || file_name.contains('/') + || file_name.contains('\\') + || path.components().count() != 1 + { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance external file {file_name} has an invalid file name" + )) + .into()); + } + + Ok(()) +} + +async fn install_shared_instance_config_bundle( + instance_id: &str, + bytes: bytes::Bytes, + state: &State, +) -> crate::Result<()> { + let files = + tokio::task::spawn_blocking(move || read_config_bundle(bytes.as_ref())) + .await??; + let metadata = crate::state::instances::commands::get_instance_metadata( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let config_path = state + .directories + .instances_dir() + .join(metadata.instance.path) + .join(CONFIG_DIRECTORY); + crate::util::io::create_dir_all(&config_path).await?; + + for (relative_path, bytes) in files { + let path = config_path.join(relative_path); + if let Some(parent) = path.parent() { + crate::util::io::create_dir_all(parent).await?; + } + crate::util::io::write(path, bytes).await?; + } + + Ok(()) +} + +fn read_config_bundle(bytes: &[u8]) -> crate::Result)>> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|error| { + crate::ErrorKind::InputError(format!( + "Invalid shared instance config bundle: {error}" + )) + })?; + if archive.len() > MAX_CONFIG_BUNDLE_ENTRIES { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle contains too many entries" + .to_string(), + ) + .into()); + } + let mut files = Vec::new(); + let mut total_size = 0; + + for index in 0..archive.len() { + let file = archive.by_index(index).map_err(|error| { + crate::ErrorKind::InputError(format!( + "Invalid shared instance config bundle entry: {error}" + )) + })?; + if file.is_dir() { + continue; + } + let path = file.enclosed_name().ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance config bundle contains an unsafe path" + .to_string(), + ) + })?; + if !is_supported_config_file(&path) { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance config bundle contains unsupported file {}", + path.display() + )) + .into()); + } + let declared_size = file.size(); + let bytes = read_bounded_config_bundle_entry( + file, + declared_size, + &mut total_size, + )?; + files.push((path.to_path_buf(), bytes)); + } + + Ok(files) +} + +fn is_supported_config_file(path: &std::path::Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + CONFIG_FILE_EXTENSIONS + .iter() + .any(|candidate| extension.eq_ignore_ascii_case(candidate)) + }) +} diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index d178335cf..55783693b 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -290,6 +290,7 @@ pub async fn install_minecraft_with_reporter( }; let state = State::get().await?; + let previous_install_stage = instance.install_stage; crate::state::instances::commands::set_instance_install_stage( &instance.id, @@ -299,6 +300,7 @@ pub async fn install_minecraft_with_reporter( .await?; emit_instance(&instance.id, InstancePayloadType::Edited).await?; + let result = async { let instance_path = get_instance_full_path(&instance.path).await?; if let Some(reporter) = &reporter { reporter @@ -618,7 +620,34 @@ pub async fn install_minecraft_with_reporter( emit_loading(loading_bar, 1.0, Some("Finished installing"))?; } - Ok(()) + Ok::<(), crate::Error>(()) + } + .await; + + if result.is_err() { + if let Err(error) = + crate::state::instances::commands::set_instance_install_stage( + &instance.id, + previous_install_stage, + &state.pool, + ) + .await + { + tracing::error!( + "Failed to restore install stage for instance {}: {error}", + instance.id + ); + } else if let Err(error) = + emit_instance(&instance.id, InstancePayloadType::Edited).await + { + tracing::error!( + "Failed to emit restored install stage for instance {}: {error}", + instance.id + ); + } + } + + result } pub async fn install_minecraft_for_instance_id_with_reporter( diff --git a/packages/app-lib/src/state/friends.rs b/packages/app-lib/src/state/friends.rs index 9e59febeb..730d54fe3 100644 --- a/packages/app-lib/src/state/friends.rs +++ b/packages/app-lib/src/state/friends.rs @@ -240,6 +240,11 @@ impl FriendsSocket { } async fn handle_notification(notification: Value) -> crate::Result<()> { + tracing::info!( + notification = %notification, + "Received websocket notification payload" + ); + if notification .get("body") .and_then(|body| body.get("type")) diff --git a/packages/app-lib/src/state/instance_types.rs b/packages/app-lib/src/state/instance_types.rs index 4fefab25a..c3b6567fa 100644 --- a/packages/app-lib/src/state/instance_types.rs +++ b/packages/app-lib/src/state/instance_types.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use std::path::Path; +use super::instances::ContentSourceKind; + #[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum InstanceInstallStage { @@ -122,6 +124,7 @@ pub struct ContentFile { pub metadata: Option, pub update_version_id: Option, pub project_type: ProjectType, + pub source_kind: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -130,7 +133,7 @@ pub struct FileMetadata { pub version_id: String, } -#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] pub enum ProjectType { Mod, @@ -186,6 +189,18 @@ impl ProjectType { } } + pub fn from_name(name: &str) -> Option { + match name { + "mod" | "mods" => Some(ProjectType::Mod), + "datapack" | "datapacks" => Some(ProjectType::DataPack), + "resourcepack" | "resourcepacks" => Some(ProjectType::ResourcePack), + "shader" | "shaderpack" | "shaderpacks" => { + Some(ProjectType::ShaderPack) + } + _ => None, + } + } + pub fn get_folder(&self) -> &'static str { match self { ProjectType::Mod => "mods", diff --git a/packages/app-lib/src/state/instances/adapters/filesystem.rs b/packages/app-lib/src/state/instances/adapters/filesystem.rs index 73c8ce4f5..2d054fc90 100644 --- a/packages/app-lib/src/state/instances/adapters/filesystem.rs +++ b/packages/app-lib/src/state/instances/adapters/filesystem.rs @@ -89,6 +89,9 @@ fn is_scannable_project_file( ProjectType::Mod => extension.eq_ignore_ascii_case("jar"), ProjectType::DataPack | ProjectType::ResourcePack - | ProjectType::ShaderPack => extension.eq_ignore_ascii_case("zip"), + | ProjectType::ShaderPack => { + extension.eq_ignore_ascii_case("zip") + || extension.eq_ignore_ascii_case("jar") + } } } diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs index 74a58bf93..ff51044eb 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs @@ -374,6 +374,56 @@ where rows.into_iter().map(TryInto::try_into).collect() } +pub(crate) async fn upsert_content_set_remote_ref( + remote_ref: &ContentSetRemoteRef, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let content_set_id = remote_ref.content_set_id.as_str(); + let ref_type = remote_ref.ref_type.as_str(); + let ref_id = remote_ref.ref_id.as_str(); + + sqlx::query!( + " + INSERT INTO instance_content_set_remote_refs ( + content_set_id, + ref_type, + ref_id + ) + VALUES (?, ?, ?) + ON CONFLICT (content_set_id, ref_type) DO UPDATE SET + ref_id = excluded.ref_id + ", + content_set_id, + ref_type, + ref_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn delete_content_set_remote_ref( + content_set_id: &str, + ref_type: ContentSetRemoteRefType, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let ref_type = ref_type.as_str(); + + sqlx::query!( + " + DELETE FROM instance_content_set_remote_refs + WHERE content_set_id = ? AND ref_type = ? + ", + content_set_id, + ref_type, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + pub(crate) async fn get_content_set_sync_state<'e, E>( content_set_id: &str, exec: E, @@ -396,6 +446,66 @@ where row.map(TryInto::try_into).transpose() } +pub(crate) async fn upsert_content_set_sync_state( + sync_state: &ContentSetSyncState, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let content_set_id = sync_state.content_set_id.as_str(); + let provider = sync_state.provider.as_str(); + let applied_update_id = sync_state.applied_update_id.as_deref(); + let latest_available_update_id = + sync_state.latest_available_update_id.as_deref(); + let checked_at = sync_state.checked_at.map(|value| value.timestamp()); + let status = sync_state.status.as_str(); + + sqlx::query!( + " + INSERT INTO instance_content_set_sync_state ( + content_set_id, + provider, + applied_update_id, + latest_available_update_id, + checked_at, + status + ) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (content_set_id) DO UPDATE SET + provider = excluded.provider, + applied_update_id = excluded.applied_update_id, + latest_available_update_id = excluded.latest_available_update_id, + checked_at = excluded.checked_at, + status = excluded.status + ", + content_set_id, + provider, + applied_update_id, + latest_available_update_id, + checked_at, + status, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn delete_content_set_sync_state( + content_set_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + " + DELETE FROM instance_content_set_sync_state + WHERE content_set_id = ? + ", + content_set_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + pub(crate) async fn get_instance_files<'e, E>( instance_id: &str, exec: E, @@ -523,6 +633,100 @@ pub(crate) async fn upsert_instance_file( row.try_into() } +async fn insert_content_entry( + entry: &ContentEntry, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let id = entry.id.as_str(); + let instance_id = entry.instance_id.as_str(); + let content_set_id = entry.content_set_id.as_str(); + let file_id = entry.file_id.as_deref(); + let project_type = entry.project_type.get_name(); + let project_id = entry.project_id.as_deref(); + let version_id = entry.version_id.as_deref(); + let source_kind = entry.source_kind.as_str(); + let server_requirement = entry.server_requirement.as_str(); + let client_requirement = entry.client_requirement.as_str(); + let enabled = i64::from(entry.enabled); + let added_at = entry.added_at.timestamp(); + let modified_at = entry.modified_at.timestamp(); + + sqlx::query( + " + INSERT INTO instance_content_entries ( + id, + instance_id, + content_set_id, + file_id, + project_type, + project_id, + version_id, + source_kind, + server_requirement, + client_requirement, + enabled, + added_at, + modified_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ", + ) + .bind(id) + .bind(instance_id) + .bind(content_set_id) + .bind(file_id) + .bind(project_type) + .bind(project_id) + .bind(version_id) + .bind(source_kind) + .bind(server_requirement) + .bind(client_requirement) + .bind(enabled) + .bind(added_at) + .bind(modified_at) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn restore_instance_content_snapshot( + instance_id: &str, + files: &[InstanceFile], + entries: &[ContentEntry], + pool: &SqlitePool, +) -> crate::Result<()> { + let mut tx = pool.begin().await?; + sqlx::query( + " + DELETE FROM instance_content_entries + WHERE instance_id = ? + ", + ) + .bind(instance_id) + .execute(&mut *tx) + .await?; + sqlx::query( + " + DELETE FROM instance_files + WHERE instance_id = ? + ", + ) + .bind(instance_id) + .execute(&mut *tx) + .await?; + + for file in files { + upsert_instance_file(file, &mut tx).await?; + } + for entry in entries { + insert_content_entry(entry, &mut tx).await?; + } + + tx.commit().await?; + Ok(()) +} + pub(crate) async fn get_content_entries<'e, E>( content_set_id: &str, exec: E, @@ -1091,16 +1295,12 @@ pub(crate) async fn upsert_content_update_check( } fn project_type_from_str(value: &str) -> crate::Result { - match value { - "mod" => Ok(ProjectType::Mod), - "datapack" => Ok(ProjectType::DataPack), - "resourcepack" => Ok(ProjectType::ResourcePack), - "shader" | "shaderpack" => Ok(ProjectType::ShaderPack), - other => Err(crate::ErrorKind::InputError(format!( - "Unknown content project type {other}" + ProjectType::from_name(value).ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown content project type {value}" )) - .into()), - } + .into() + }) } fn timestamp(value: i64) -> DateTime { diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index 5a4a0e614..6e97bf4ab 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -1,9 +1,10 @@ #![allow(dead_code)] use crate::state::instances::{ - ContentSet, ContentSetStatus, ContentSourceKind, Instance, - InstanceLaunchContext, InstanceLaunchOverrides, - InstanceLaunchOverridesData, InstanceLink, playtime_to_storage, + ContentSet, ContentSetStatus, ContentSetSyncStatus, ContentSourceKind, + Instance, InstanceLaunchContext, InstanceLaunchOverrides, + InstanceLaunchOverridesData, InstanceLink, SharedInstanceAttachment, + SharedInstanceRole, playtime_to_storage, }; use crate::state::{ InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel, @@ -73,6 +74,9 @@ pub(crate) struct InstanceLinkRow { pub hosting_instance_ids: Option, pub hosting_active_instance_id: Option, pub shared_instance_id: Option, + pub shared_instance_role: Option, + pub shared_instance_manager_id: Option, + pub shared_instance_linked_user_id: Option, pub imported_name: Option, pub imported_version_number: Option, pub imported_filename: Option, @@ -137,10 +141,8 @@ impl TryFrom for InstanceLink { filename: row.imported_filename, }), "shared_instance" => Ok(Self::SharedInstance { - shared_instance_id: parse_uuid( - row.shared_instance_id, - "shared_instance_id", - )?, + modpack_project_id: row.modrinth_project_id, + modpack_version_id: row.modrinth_version_id, }), other => Err(crate::ErrorKind::InputError(format!( "Unknown instance link kind {other}" @@ -161,6 +163,7 @@ pub(crate) struct InstanceMetadataRecord { pub instance: Instance, pub applied_content_set: ContentSet, pub link: InstanceLink, + pub shared_instance: Option, pub groups: Vec, pub launch_overrides: InstanceLaunchOverrides, } @@ -207,6 +210,14 @@ struct InstanceMetadataRow { hosting_instance_ids: Option, hosting_active_instance_id: Option, shared_instance_id: Option, + shared_instance_role: Option, + shared_instance_manager_id: Option, + shared_instance_server_manager_name: Option, + shared_instance_server_manager_icon_url: Option, + shared_instance_linked_user_id: Option, + shared_sync_applied_update_id: Option, + shared_sync_latest_available_update_id: Option, + shared_sync_status: Option, imported_name: Option, imported_version_number: Option, imported_filename: Option, @@ -300,12 +311,28 @@ impl InstanceMetadataRow { hosting_server_id: self.hosting_server_id, hosting_instance_ids: self.hosting_instance_ids, hosting_active_instance_id: self.hosting_active_instance_id, - shared_instance_id: self.shared_instance_id, + shared_instance_id: self.shared_instance_id.clone(), + shared_instance_role: self.shared_instance_role.clone(), + shared_instance_manager_id: self.shared_instance_manager_id.clone(), + shared_instance_linked_user_id: self + .shared_instance_linked_user_id + .clone(), imported_name: self.imported_name, imported_version_number: self.imported_version_number, imported_filename: self.imported_filename, } .try_into()?; + let shared_instance = shared_instance_attachment( + self.shared_instance_id, + self.shared_instance_role, + self.shared_instance_manager_id, + self.shared_instance_server_manager_name, + self.shared_instance_server_manager_icon_url, + self.shared_instance_linked_user_id, + self.shared_sync_status, + self.shared_sync_applied_update_id, + self.shared_sync_latest_available_update_id, + )?; let groups = parse_groups(self.groups)?; let launch_overrides = launch_overrides_from_json(instance_id, self.launch_overrides)?; @@ -314,6 +341,7 @@ impl InstanceMetadataRow { instance, applied_content_set, link, + shared_instance, groups, launch_overrides, }) @@ -418,75 +446,173 @@ where Ok(row) } +pub(crate) async fn is_instance_quarantined<'e, E>( + instance_id: &str, + exec: E, +) -> crate::Result +where + E: Executor<'e, Database = Sqlite>, +{ + let quarantined = sqlx::query_scalar::<_, bool>( + " + SELECT EXISTS ( + SELECT 1 + FROM instance_quarantines + WHERE instance_id = ? + ) + ", + ) + .bind(instance_id) + .fetch_one(exec) + .await?; + + Ok(quarantined) +} + +pub(crate) async fn get_quarantined_instance_ids<'e, E>( + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let instance_ids = sqlx::query_scalar::<_, String>( + " + SELECT instance_id + FROM instance_quarantines + ", + ) + .fetch_all(exec) + .await?; + + Ok(instance_ids.into_iter().collect()) +} + +pub(crate) async fn set_instance_quarantined( + instance_id: &str, + quarantined: bool, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + if quarantined { + sqlx::query( + " + INSERT INTO instance_quarantines (instance_id) + VALUES (?) + ON CONFLICT (instance_id) DO NOTHING + ", + ) + .bind(instance_id) + .execute(&mut **tx) + .await?; + } else { + sqlx::query( + " + DELETE FROM instance_quarantines + WHERE instance_id = ? + ", + ) + .bind(instance_id) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +macro_rules! query_instance_metadata { + ( + $prefix:literal, + $from:literal, + $suffix:literal, + $arg:expr $(,)? + ) => { + sqlx::query_as!( + InstanceMetadataRow, + $prefix + + r#" + SELECT + i.id AS "id!: String", + i.path AS "path!: String", + i.applied_content_set_id AS "applied_content_set_id?: String", + i.install_stage AS "install_stage!: String", + i.launcher_feature_version AS "launcher_feature_version!: String", + i.update_channel AS "update_channel!: String", + i.name AS "name!: String", + i.icon_path AS "icon_path?: String", + i.created AS "created!: i64", + i.modified AS "modified!: i64", + i.last_played AS "last_played?: i64", + i.submitted_time_played AS "submitted_time_played!: i64", + i.recent_time_played AS "recent_time_played!: i64", + cs.id AS "content_set_id?: String", + cs.instance_id AS "content_set_instance_id?: String", + cs.name AS "content_set_name?: String", + cs.source_kind AS "content_set_source_kind?: String", + cs.status AS "content_set_status?: String", + cs.game_version AS "content_set_game_version?: String", + cs.protocol_version AS "content_set_protocol_version?: i64", + cs.loader AS "content_set_loader?: String", + cs.loader_version AS "content_set_loader_version?: String", + cs.created AS "content_set_created?: i64", + cs.modified AS "content_set_modified?: i64", + COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", + link.modrinth_project_id AS "modrinth_project_id?: String", + link.modrinth_version_id AS "modrinth_version_id?: String", + link.server_project_id AS "server_project_id?: String", + link.content_project_id AS "content_project_id?: String", + link.content_version_id AS "content_version_id?: String", + link.hosting_server_id AS "hosting_server_id?: String", + json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", + link.hosting_active_instance_id AS "hosting_active_instance_id?: String", + link.shared_instance_id AS "shared_instance_id?: String", + link.shared_instance_role AS "shared_instance_role?: String", + link.shared_instance_manager_id AS "shared_instance_manager_id?: String", + link.shared_instance_server_manager_name AS "shared_instance_server_manager_name?: String", + link.shared_instance_server_manager_icon_url AS "shared_instance_server_manager_icon_url?: String", + link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String", + sync.applied_update_id AS "shared_sync_applied_update_id?: String", + sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String", + sync.status AS "shared_sync_status?: String", + link.imported_name AS "imported_name?: String", + link.imported_version_number AS "imported_version_number?: String", + link.imported_filename AS "imported_filename?: String", + COALESCE(( + SELECT json_group_array(group_name) + FROM ( + SELECT group_name + FROM instance_groups + WHERE instance_id = i.id + ORDER BY group_name + ) + ), '[]') AS "groups!: String", + json(overrides.overrides) AS "launch_overrides?: String" + "# + + $from + + r#" + LEFT JOIN instance_content_sets cs + ON cs.id = i.applied_content_set_id + AND cs.instance_id = i.id + LEFT JOIN instance_links link + ON link.instance_id = i.id + LEFT JOIN instance_content_set_sync_state sync + ON sync.content_set_id = cs.id + AND sync.provider = 'shared_instance' + LEFT JOIN instance_launch_overrides overrides + ON overrides.instance_id = i.id + "# + + $suffix, + $arg, + ) + }; +} + pub(crate) async fn get_instance_metadata_by_id( id: &str, pool: &SqlitePool, ) -> crate::Result> { - let row = sqlx::query_as!( - InstanceMetadataRow, - r#" - SELECT - i.id AS "id!: String", - i.path AS "path!: String", - i.applied_content_set_id AS "applied_content_set_id?: String", - i.install_stage AS "install_stage!: String", - i.launcher_feature_version AS "launcher_feature_version!: String", - i.update_channel AS "update_channel!: String", - i.name AS "name!: String", - i.icon_path AS "icon_path?: String", - i.created AS "created!: i64", - i.modified AS "modified!: i64", - i.last_played AS "last_played?: i64", - i.submitted_time_played AS "submitted_time_played!: i64", - i.recent_time_played AS "recent_time_played!: i64", - cs.id AS "content_set_id?: String", - cs.instance_id AS "content_set_instance_id?: String", - cs.name AS "content_set_name?: String", - cs.source_kind AS "content_set_source_kind?: String", - cs.status AS "content_set_status?: String", - cs.game_version AS "content_set_game_version?: String", - cs.protocol_version AS "content_set_protocol_version?: i64", - cs.loader AS "content_set_loader?: String", - cs.loader_version AS "content_set_loader_version?: String", - cs.created AS "content_set_created?: i64", - cs.modified AS "content_set_modified?: i64", - COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", - link.modrinth_project_id AS "modrinth_project_id?: String", - link.modrinth_version_id AS "modrinth_version_id?: String", - link.server_project_id AS "server_project_id?: String", - link.content_project_id AS "content_project_id?: String", - link.content_version_id AS "content_version_id?: String", - link.hosting_server_id AS "hosting_server_id?: String", - json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", - link.hosting_active_instance_id AS "hosting_active_instance_id?: String", - link.shared_instance_id AS "shared_instance_id?: String", - link.imported_name AS "imported_name?: String", - link.imported_version_number AS "imported_version_number?: String", - link.imported_filename AS "imported_filename?: String", - COALESCE(( - SELECT json_group_array(group_name) - FROM ( - SELECT group_name - FROM instance_groups - WHERE instance_id = i.id - ORDER BY group_name - ) - ), '[]') AS "groups!: String", - json(overrides.overrides) AS "launch_overrides?: String" - FROM instances i - LEFT JOIN instance_content_sets cs - ON cs.id = i.applied_content_set_id - AND cs.instance_id = i.id - LEFT JOIN instance_links link - ON link.instance_id = i.id - LEFT JOIN instance_launch_overrides overrides - ON overrides.instance_id = i.id - WHERE i.id = ? - "#, - id, - ) - .fetch_optional(pool) - .await?; + let row = + query_instance_metadata!("", "FROM instances i", "WHERE i.id = ?", id,) + .fetch_optional(pool) + .await?; row.map(InstanceMetadataRow::into_record).transpose() } @@ -500,73 +626,19 @@ pub(crate) async fn get_instance_metadata_many( } let ids_json = serde_json::to_string(ids)?; - let rows = sqlx::query_as!( - InstanceMetadataRow, + let rows = query_instance_metadata!( r#" WITH requested AS ( SELECT value AS id, key AS ord FROM json_each(?) ) - SELECT - i.id AS "id!: String", - i.path AS "path!: String", - i.applied_content_set_id AS "applied_content_set_id?: String", - i.install_stage AS "install_stage!: String", - i.launcher_feature_version AS "launcher_feature_version!: String", - i.update_channel AS "update_channel!: String", - i.name AS "name!: String", - i.icon_path AS "icon_path?: String", - i.created AS "created!: i64", - i.modified AS "modified!: i64", - i.last_played AS "last_played?: i64", - i.submitted_time_played AS "submitted_time_played!: i64", - i.recent_time_played AS "recent_time_played!: i64", - cs.id AS "content_set_id?: String", - cs.instance_id AS "content_set_instance_id?: String", - cs.name AS "content_set_name?: String", - cs.source_kind AS "content_set_source_kind?: String", - cs.status AS "content_set_status?: String", - cs.game_version AS "content_set_game_version?: String", - cs.protocol_version AS "content_set_protocol_version?: i64", - cs.loader AS "content_set_loader?: String", - cs.loader_version AS "content_set_loader_version?: String", - cs.created AS "content_set_created?: i64", - cs.modified AS "content_set_modified?: i64", - COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", - link.modrinth_project_id AS "modrinth_project_id?: String", - link.modrinth_version_id AS "modrinth_version_id?: String", - link.server_project_id AS "server_project_id?: String", - link.content_project_id AS "content_project_id?: String", - link.content_version_id AS "content_version_id?: String", - link.hosting_server_id AS "hosting_server_id?: String", - json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", - link.hosting_active_instance_id AS "hosting_active_instance_id?: String", - link.shared_instance_id AS "shared_instance_id?: String", - link.imported_name AS "imported_name?: String", - link.imported_version_number AS "imported_version_number?: String", - link.imported_filename AS "imported_filename?: String", - COALESCE(( - SELECT json_group_array(group_name) - FROM ( - SELECT group_name - FROM instance_groups - WHERE instance_id = i.id - ORDER BY group_name - ) - ), '[]') AS "groups!: String", - json(overrides.overrides) AS "launch_overrides?: String" + "#, + r#" FROM requested INNER JOIN instances i ON i.id = requested.id - LEFT JOIN instance_content_sets cs - ON cs.id = i.applied_content_set_id - AND cs.instance_id = i.id - LEFT JOIN instance_links link - ON link.instance_id = i.id - LEFT JOIN instance_launch_overrides overrides - ON overrides.instance_id = i.id - ORDER BY requested.ord "#, + "ORDER BY requested.ord", ids_json, ) .fetch_all(pool) @@ -580,69 +652,10 @@ pub(crate) async fn get_instance_metadata_many( pub(crate) async fn list_instance_metadata( pool: &SqlitePool, ) -> crate::Result> { - let rows = sqlx::query_as!( - InstanceMetadataRow, - r#" - SELECT - i.id AS "id!: String", - i.path AS "path!: String", - i.applied_content_set_id AS "applied_content_set_id?: String", - i.install_stage AS "install_stage!: String", - i.launcher_feature_version AS "launcher_feature_version!: String", - i.update_channel AS "update_channel!: String", - i.name AS "name!: String", - i.icon_path AS "icon_path?: String", - i.created AS "created!: i64", - i.modified AS "modified!: i64", - i.last_played AS "last_played?: i64", - i.submitted_time_played AS "submitted_time_played!: i64", - i.recent_time_played AS "recent_time_played!: i64", - cs.id AS "content_set_id?: String", - cs.instance_id AS "content_set_instance_id?: String", - cs.name AS "content_set_name?: String", - cs.source_kind AS "content_set_source_kind?: String", - cs.status AS "content_set_status?: String", - cs.game_version AS "content_set_game_version?: String", - cs.protocol_version AS "content_set_protocol_version?: i64", - cs.loader AS "content_set_loader?: String", - cs.loader_version AS "content_set_loader_version?: String", - cs.created AS "content_set_created?: i64", - cs.modified AS "content_set_modified?: i64", - COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", - link.modrinth_project_id AS "modrinth_project_id?: String", - link.modrinth_version_id AS "modrinth_version_id?: String", - link.server_project_id AS "server_project_id?: String", - link.content_project_id AS "content_project_id?: String", - link.content_version_id AS "content_version_id?: String", - link.hosting_server_id AS "hosting_server_id?: String", - json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", - link.hosting_active_instance_id AS "hosting_active_instance_id?: String", - link.shared_instance_id AS "shared_instance_id?: String", - link.imported_name AS "imported_name?: String", - link.imported_version_number AS "imported_version_number?: String", - link.imported_filename AS "imported_filename?: String", - COALESCE(( - SELECT json_group_array(group_name) - FROM ( - SELECT group_name - FROM instance_groups - WHERE instance_id = i.id - ORDER BY group_name - ) - ), '[]') AS "groups!: String", - json(overrides.overrides) AS "launch_overrides?: String" - FROM instances i - LEFT JOIN instance_content_sets cs - ON cs.id = i.applied_content_set_id - AND cs.instance_id = i.id - LEFT JOIN instance_links link - ON link.instance_id = i.id - LEFT JOIN instance_launch_overrides overrides - ON overrides.instance_id = i.id - "#, - ) - .fetch_all(pool) - .await?; + let rows = + query_instance_metadata!("", "FROM instances i", "WHERE 1 = ?", 1_i64,) + .fetch_all(pool) + .await?; rows.into_iter() .map(InstanceMetadataRow::into_record) @@ -653,59 +666,10 @@ pub(crate) async fn get_instance_launch_context( instance_id: &str, pool: &SqlitePool, ) -> crate::Result> { - let row = sqlx::query_as!( - InstanceMetadataRow, - r#" - SELECT - i.id AS "id!: String", - i.path AS "path!: String", - i.applied_content_set_id AS "applied_content_set_id?: String", - i.install_stage AS "install_stage!: String", - i.launcher_feature_version AS "launcher_feature_version!: String", - i.update_channel AS "update_channel!: String", - i.name AS "name!: String", - i.icon_path AS "icon_path?: String", - i.created AS "created!: i64", - i.modified AS "modified!: i64", - i.last_played AS "last_played?: i64", - i.submitted_time_played AS "submitted_time_played!: i64", - i.recent_time_played AS "recent_time_played!: i64", - cs.id AS "content_set_id?: String", - cs.instance_id AS "content_set_instance_id?: String", - cs.name AS "content_set_name?: String", - cs.source_kind AS "content_set_source_kind?: String", - cs.status AS "content_set_status?: String", - cs.game_version AS "content_set_game_version?: String", - cs.protocol_version AS "content_set_protocol_version?: i64", - cs.loader AS "content_set_loader?: String", - cs.loader_version AS "content_set_loader_version?: String", - cs.created AS "content_set_created?: i64", - cs.modified AS "content_set_modified?: i64", - COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", - link.modrinth_project_id AS "modrinth_project_id?: String", - link.modrinth_version_id AS "modrinth_version_id?: String", - link.server_project_id AS "server_project_id?: String", - link.content_project_id AS "content_project_id?: String", - link.content_version_id AS "content_version_id?: String", - link.hosting_server_id AS "hosting_server_id?: String", - json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", - link.hosting_active_instance_id AS "hosting_active_instance_id?: String", - link.shared_instance_id AS "shared_instance_id?: String", - link.imported_name AS "imported_name?: String", - link.imported_version_number AS "imported_version_number?: String", - link.imported_filename AS "imported_filename?: String", - '[]' AS "groups!: String", - json(overrides.overrides) AS "launch_overrides?: String" - FROM instances i - LEFT JOIN instance_content_sets cs - ON cs.id = i.applied_content_set_id - AND cs.instance_id = i.id - LEFT JOIN instance_links link - ON link.instance_id = i.id - LEFT JOIN instance_launch_overrides overrides - ON overrides.instance_id = i.id - WHERE i.id = ? - "#, + let row = query_instance_metadata!( + "", + "FROM instances i", + "WHERE i.id = ?", instance_id, ) .fetch_optional(pool) @@ -753,6 +717,9 @@ where json(hosting_instance_ids) AS "hosting_instance_ids?: String", hosting_active_instance_id, shared_instance_id, + shared_instance_role, + shared_instance_manager_id, + shared_instance_linked_user_id, imported_name, imported_version_number, imported_filename @@ -949,7 +916,6 @@ pub(crate) async fn upsert_instance_link( let hosting_instance_ids = columns.hosting_instance_ids.as_deref(); let hosting_active_instance_id = columns.hosting_active_instance_id.as_deref(); - let shared_instance_id = columns.shared_instance_id.as_deref(); let imported_name = columns.imported_name.as_deref(); let imported_version_number = columns.imported_version_number.as_deref(); let imported_filename = columns.imported_filename.as_deref(); @@ -967,12 +933,11 @@ pub(crate) async fn upsert_instance_link( hosting_server_id, hosting_instance_ids, hosting_active_instance_id, - shared_instance_id, imported_name, imported_version_number, imported_filename ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?) ON CONFLICT (instance_id) DO UPDATE SET link_kind = excluded.link_kind, modrinth_project_id = excluded.modrinth_project_id, @@ -983,7 +948,6 @@ pub(crate) async fn upsert_instance_link( hosting_server_id = excluded.hosting_server_id, hosting_instance_ids = excluded.hosting_instance_ids, hosting_active_instance_id = excluded.hosting_active_instance_id, - shared_instance_id = excluded.shared_instance_id, imported_name = excluded.imported_name, imported_version_number = excluded.imported_version_number, imported_filename = excluded.imported_filename @@ -998,7 +962,6 @@ pub(crate) async fn upsert_instance_link( hosting_server_id, hosting_instance_ids, hosting_active_instance_id, - shared_instance_id, imported_name, imported_version_number, imported_filename, @@ -1009,6 +972,64 @@ pub(crate) async fn upsert_instance_link( Ok(()) } +pub(crate) async fn set_shared_instance_attachment( + instance_id: &str, + attachment: Option<&SharedInstanceAttachment>, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let shared_instance_id = attachment.map(|value| value.id.to_string()); + let shared_instance_role = + attachment.map(|value| value.role.as_str().to_string()); + let shared_instance_manager_id = + attachment.and_then(|value| value.manager_id.as_deref()); + let shared_instance_linked_user_id = + attachment.and_then(|value| value.linked_user_id.as_deref()); + let shared_instance_server_manager_name = + attachment.and_then(|value| value.server_manager_name.as_deref()); + let shared_instance_server_manager_icon_url = + attachment.and_then(|value| value.server_manager_icon_url.as_deref()); + + sqlx::query!( + " + INSERT INTO instance_links ( + instance_id, + link_kind, + shared_instance_id, + shared_instance_role, + shared_instance_manager_id, + shared_instance_server_manager_name, + shared_instance_server_manager_icon_url, + shared_instance_linked_user_id + ) + VALUES (?, 'unmanaged', ?, ?, ?, ?, ?, ?) + ON CONFLICT (instance_id) DO UPDATE SET + link_kind = CASE + WHEN excluded.shared_instance_id IS NULL + AND instance_links.link_kind = 'shared_instance' + THEN 'unmanaged' + ELSE instance_links.link_kind + END, + shared_instance_id = excluded.shared_instance_id, + shared_instance_role = excluded.shared_instance_role, + shared_instance_manager_id = excluded.shared_instance_manager_id, + shared_instance_server_manager_name = excluded.shared_instance_server_manager_name, + shared_instance_server_manager_icon_url = excluded.shared_instance_server_manager_icon_url, + shared_instance_linked_user_id = excluded.shared_instance_linked_user_id + ", + instance_id, + shared_instance_id, + shared_instance_role, + shared_instance_manager_id, + shared_instance_server_manager_name, + shared_instance_server_manager_icon_url, + shared_instance_linked_user_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + pub(crate) async fn replace_instance_groups( instance_id: &str, groups: &[String], @@ -1094,7 +1115,6 @@ struct InstanceLinkColumns { hosting_server_id: Option, hosting_instance_ids: Option, hosting_active_instance_id: Option, - shared_instance_id: Option, imported_name: Option, imported_version_number: Option, imported_filename: Option, @@ -1114,7 +1134,6 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1132,7 +1151,6 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1147,7 +1165,6 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1166,7 +1183,6 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1186,7 +1202,6 @@ fn instance_link_columns( hosting_instance_ids: Some(serde_json::to_string(instance_ids)?), hosting_active_instance_id: active_instance_id .map(|value| value.to_string()), - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1207,28 +1222,27 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: name.clone(), imported_version_number: version_number.clone(), imported_filename: filename.clone(), }), - InstanceLink::SharedInstance { shared_instance_id } => { - Ok(InstanceLinkColumns { - link_kind: "shared_instance", - modrinth_project_id: None, - modrinth_version_id: None, - server_project_id: None, - content_project_id: None, - content_version_id: None, - hosting_server_id: None, - hosting_instance_ids: None, - hosting_active_instance_id: None, - shared_instance_id: Some(shared_instance_id.to_string()), - imported_name: None, - imported_version_number: None, - imported_filename: None, - }) - } + InstanceLink::SharedInstance { + modpack_project_id, + modpack_version_id, + } => Ok(InstanceLinkColumns { + link_kind: "shared_instance", + modrinth_project_id: modpack_project_id.clone(), + modrinth_version_id: modpack_version_id.clone(), + server_project_id: None, + content_project_id: None, + content_version_id: None, + hosting_server_id: None, + hosting_instance_ids: None, + hosting_active_instance_id: None, + imported_name: None, + imported_version_number: None, + imported_filename: None, + }), } } @@ -1273,6 +1287,60 @@ fn launch_overrides_from_json( } } +fn shared_instance_attachment( + shared_instance_id: Option, + shared_instance_role: Option, + shared_instance_manager_id: Option, + shared_instance_server_manager_name: Option, + shared_instance_server_manager_icon_url: Option, + shared_instance_linked_user_id: Option, + shared_sync_status: Option, + applied_update_id: Option, + latest_available_update_id: Option, +) -> crate::Result> { + let Some(id) = shared_instance_id else { + return Ok(None); + }; + + let role = match shared_instance_role { + Some(role) => SharedInstanceRole::from_stored_str(&role)?, + None => SharedInstanceRole::Member, + }; + let status = match shared_sync_status { + Some(status) => ContentSetSyncStatus::from_str(&status)?, + None => ContentSetSyncStatus::Unknown, + }; + + Ok(Some(SharedInstanceAttachment { + id, + role, + manager_id: shared_instance_manager_id, + server_manager_name: shared_instance_server_manager_name, + server_manager_icon_url: shared_instance_server_manager_icon_url, + linked_user_id: shared_instance_linked_user_id, + status, + applied_version: optional_i32(applied_update_id, "applied_update_id")?, + latest_version: optional_i32( + latest_available_update_id, + "latest_available_update_id", + )?, + })) +} + +fn optional_i32( + value: Option, + column: &str, +) -> crate::Result> { + value + .map(|value| { + value.parse().map_err(|err| { + crate::ErrorKind::InputError(format!("Invalid {column}: {err}")) + .into() + }) + }) + .transpose() +} + fn parse_uuid(value: Option, column: &str) -> crate::Result { let value = required(value, column)?; diff --git a/packages/app-lib/src/state/instances/commands/apply_content_install.rs b/packages/app-lib/src/state/instances/commands/apply_content_install.rs index 49536fb69..c3e036d53 100644 --- a/packages/app-lib/src/state/instances/commands/apply_content_install.rs +++ b/packages/app-lib/src/state/instances/commands/apply_content_install.rs @@ -502,6 +502,13 @@ pub(crate) async fn add_project_bytes( version_id: Option<&str>, state: &State, ) -> crate::Result { + if !path_util::is_safe_file_name(file_name) { + return Err(crate::ErrorKind::InputError(format!( + "Project file {file_name} has an invalid file name" + )) + .into()); + } + let _content_lock = state.lock_instance_content(instance_id).await; let scope = resolve_content_scope(instance_id, None, state).await?; let project_type = match project_type { @@ -561,6 +568,7 @@ pub(crate) async fn add_project_bytes( ) .await?; tx.commit().await?; + super::mark_shared_instance_stale(instance_id, &state.pool).await?; Ok(relative_path) } @@ -608,6 +616,7 @@ pub(crate) async fn record_project_file( ) .await?; tx.commit().await?; + super::mark_shared_instance_stale(instance_id, &state.pool).await?; Ok(()) } @@ -711,6 +720,8 @@ pub(crate) async fn toggle_disable_project( } tx.commit().await?; + super::mark_shared_instance_stale(instance_id, &state.pool).await?; + Ok(new_path) } @@ -752,9 +763,36 @@ pub(crate) async fn remove_project( tx.commit().await?; } + super::mark_shared_instance_stale(instance_id, &state.pool).await?; + Ok(()) } +pub(crate) async fn content_source_kind_for_project_path( + instance_id: &str, + project_path: &str, + state: &State, +) -> crate::Result> { + let scope = resolve_content_scope(instance_id, None, state).await?; + let Some(file) = content_rows::get_instance_file_by_relative_path( + &scope.instance.id, + project_path, + &state.pool, + ) + .await? + else { + return Ok(None); + }; + let entries = + content_rows::get_content_entries(&scope.content_set_id, &state.pool) + .await?; + + Ok(entries.into_iter().find_map(|entry| { + (entry.file_id.as_deref() == Some(file.id.as_str())) + .then_some(entry.source_kind) + })) +} + pub(crate) async fn rename_project_companion_file( instance_id: &str, old_project_path: &str, diff --git a/packages/app-lib/src/state/instances/commands/apply_content_update.rs b/packages/app-lib/src/state/instances/commands/apply_content_update.rs index 09aa35892..232e36130 100644 --- a/packages/app-lib/src/state/instances/commands/apply_content_update.rs +++ b/packages/app-lib/src/state/instances/commands/apply_content_update.rs @@ -52,6 +52,7 @@ struct InstalledProject { relative_path: String, project_id: Option, version_id: Option, + source_kind: ContentSourceKind, enabled: bool, } @@ -295,8 +296,14 @@ async fn plan_bulk_update( instance_id: &str, state: &State, ) -> crate::Result { - let updateable_paths = - bulk_updateable_project_paths(instance_id, state).await?; + let shared_instance_member = + is_shared_instance_member(instance_id, state).await?; + let updateable_paths = bulk_updateable_project_paths( + instance_id, + shared_instance_member, + state, + ) + .await?; if updateable_paths.is_empty() { return Ok(BulkUpdatePlan { project_updates: Vec::new(), @@ -330,6 +337,30 @@ async fn plan_bulk_update( })?; let installed = installed_projects(instance_id, &content_set, state).await?; + let updateable_paths = if shared_instance_member { + let managed_paths = installed + .iter() + .filter(|project| project.source_kind.is_shared_instance_managed()) + .map(|project| project.relative_path.clone()) + .collect::>(); + + updateable_paths + .into_iter() + .filter(|path| !managed_paths.contains(path)) + .collect::>() + } else { + updateable_paths + }; + let updates = updates + .into_iter() + .filter(|update| updateable_paths.contains(&update.relative_path)) + .collect::>(); + if updates.is_empty() { + return Ok(BulkUpdatePlan { + project_updates: Vec::new(), + dependency_additions: Vec::new(), + }); + } let installed_by_project = installed .iter() .filter_map(|project| { @@ -416,6 +447,7 @@ async fn plan_bulk_update( async fn bulk_updateable_project_paths( instance_id: &str, + shared_instance_member: bool, state: &State, ) -> crate::Result> { let items = super::list_content::list_content( @@ -426,7 +458,16 @@ async fn bulk_updateable_project_paths( ) .await?; - Ok(items.into_iter().map(|item| item.file_path).collect()) + Ok(items + .into_iter() + .filter(|item| { + !shared_instance_member + || !item + .source_kind + .is_some_and(ContentSourceKind::is_shared_instance_managed) + }) + .map(|item| item.file_path) + .collect()) } async fn installed_projects( @@ -471,10 +512,27 @@ fn installed_project_from_row( relative_path: file.relative_path.clone(), project_id: entry.project_id.clone(), version_id: entry.version_id.clone(), + source_kind: entry.source_kind, enabled: entry.enabled && file.enabled, }) } +async fn is_shared_instance_member( + instance_id: &str, + state: &State, +) -> crate::Result { + let Some(metadata) = + instance_rows::get_instance_metadata_by_id(instance_id, &state.pool) + .await? + else { + return Ok(false); + }; + + Ok(metadata + .shared_instance + .is_some_and(|attachment| attachment.role.is_member())) +} + async fn dependency_closure( root_versions: Vec, content_set: &ContentSet, diff --git a/packages/app-lib/src/state/instances/commands/edit_instance.rs b/packages/app-lib/src/state/instances/commands/edit_instance.rs index f57eb70d4..11a8b6ec6 100644 --- a/packages/app-lib/src/state/instances/commands/edit_instance.rs +++ b/packages/app-lib/src/state/instances/commands/edit_instance.rs @@ -102,11 +102,28 @@ pub(crate) async fn edit_instance( patch: EditInstance, pool: &SqlitePool, ) -> crate::Result { + let modifies_content = + patch.link.is_some() || patch.content_set_patch.is_some(); + let should_mark_shared_instance_stale = patch.link.is_some() + || patch.content_set_patch.as_ref().is_some_and(|patch| { + patch.source_kind.is_some() + || patch.game_version.is_some() + || patch.loader.is_some() + || patch.loader_version.is_some() + }); let mut instance = instance_rows::get_instance_by_id(instance_id, pool) .await? .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; + if modifies_content + && instance_rows::is_instance_quarantined(instance_id, pool).await? + { + return Err(crate::ErrorKind::InputError( + "Content in quarantined instances cannot be changed.".to_string(), + ) + .into()); + } let now = Utc::now(); apply_instance_patch(&mut instance, &patch, now); @@ -169,6 +186,10 @@ pub(crate) async fn edit_instance( tx.commit().await?; + if should_mark_shared_instance_stale { + super::mark_shared_instance_stale(instance_id, pool).await?; + } + Ok(instance) } diff --git a/packages/app-lib/src/state/instances/commands/get_instance.rs b/packages/app-lib/src/state/instances/commands/get_instance.rs index 87be04dfe..b404290d8 100644 --- a/packages/app-lib/src/state/instances/commands/get_instance.rs +++ b/packages/app-lib/src/state/instances/commands/get_instance.rs @@ -1,6 +1,6 @@ use crate::state::instances::{ ContentSet, Instance, InstanceLaunchOverrides, InstanceLink, - adapters::sqlite::instance_rows, + SharedInstanceAttachment, adapters::sqlite::instance_rows, }; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; @@ -10,6 +10,9 @@ pub struct InstanceMetadata { pub instance: Instance, pub applied_content_set: ContentSet, pub link: InstanceLink, + pub shared_instance: Option, + #[serde(default)] + pub quarantined: bool, pub groups: Vec, pub launch_overrides: InstanceLaunchOverrides, } @@ -25,44 +28,62 @@ pub(crate) async fn get_instance_metadata( instance_id: &str, pool: &SqlitePool, ) -> crate::Result> { - Ok( - instance_rows::get_instance_metadata_by_id(instance_id, pool) - .await? - .map(Into::into), - ) + let Some(record) = + instance_rows::get_instance_metadata_by_id(instance_id, pool).await? + else { + return Ok(None); + }; + let quarantined = + instance_rows::is_instance_quarantined(instance_id, pool).await?; + + Ok(Some(instance_metadata(record, quarantined))) } pub(crate) async fn get_instances_metadata( instance_ids: &[&str], pool: &SqlitePool, ) -> crate::Result> { - Ok( - instance_rows::get_instance_metadata_many(instance_ids, pool) - .await? - .into_iter() - .map(Into::into) - .collect(), - ) + let records = + instance_rows::get_instance_metadata_many(instance_ids, pool).await?; + let quarantined_ids = + instance_rows::get_quarantined_instance_ids(pool).await?; + + Ok(records + .into_iter() + .map(|record| { + let quarantined = quarantined_ids.contains(&record.instance.id); + instance_metadata(record, quarantined) + }) + .collect()) } pub(crate) async fn list_instances( pool: &SqlitePool, ) -> crate::Result> { - Ok(instance_rows::list_instance_metadata(pool) - .await? + let records = instance_rows::list_instance_metadata(pool).await?; + let quarantined_ids = + instance_rows::get_quarantined_instance_ids(pool).await?; + + Ok(records .into_iter() - .map(Into::into) + .map(|record| { + let quarantined = quarantined_ids.contains(&record.instance.id); + instance_metadata(record, quarantined) + }) .collect()) } -impl From for InstanceMetadata { - fn from(record: instance_rows::InstanceMetadataRecord) -> Self { - Self { - instance: record.instance, - applied_content_set: record.applied_content_set, - link: record.link, - groups: record.groups, - launch_overrides: record.launch_overrides, - } +fn instance_metadata( + record: instance_rows::InstanceMetadataRecord, + quarantined: bool, +) -> InstanceMetadata { + InstanceMetadata { + instance: record.instance, + applied_content_set: record.applied_content_set, + link: record.link, + shared_instance: record.shared_instance, + quarantined, + groups: record.groups, + launch_overrides: record.launch_overrides, } } diff --git a/packages/app-lib/src/state/instances/commands/list_content.rs b/packages/app-lib/src/state/instances/commands/list_content.rs index 4272f2990..69c2d8646 100644 --- a/packages/app-lib/src/state/instances/commands/list_content.rs +++ b/packages/app-lib/src/state/instances/commands/list_content.rs @@ -528,6 +528,7 @@ pub(crate) async fn dependencies_to_content_items( has_update: false, update_version_id: None, date_added: None, + source_kind: None, }) }) .collect::>(); @@ -738,6 +739,7 @@ async fn content_projects_for_scope( size: file.size, metadata: file_metadata_from_entry_or_cache(entry, metadata), project_type, + source_kind: entry.map(|entry| entry.source_kind), }, ); } @@ -897,9 +899,13 @@ async fn content_files_to_content_items( date_published: Some(version.date_published.to_rfc3339()), }), owner, - has_update: file.update_version_id.is_some(), + has_update: file.update_version_id.is_some() + && !file.source_kind.is_some_and( + ContentSourceKind::is_shared_instance_managed, + ), update_version_id: file.update_version_id.clone(), date_added: modification_times[index].clone(), + source_kind: file.source_kind, } }) .collect::>(); @@ -1089,6 +1095,10 @@ fn linked_modpack_ids(link: &InstanceLink) -> Option<(String, String)> { version_id: Some(version_id), .. } => Some((project_id.clone(), version_id.clone())), + InstanceLink::SharedInstance { + modpack_project_id: Some(project_id), + modpack_version_id: Some(version_id), + } => Some((project_id.clone(), version_id.clone())), _ => None, } } @@ -1103,6 +1113,10 @@ fn linked_modpack_source_kind( InstanceLink::ServerProjectModpack { .. } => { Some(ContentSourceKind::ServerProject) } + InstanceLink::SharedInstance { + modpack_project_id: Some(_), + modpack_version_id: Some(_), + } => Some(ContentSourceKind::ModrinthModpack), _ => None, } } @@ -1348,12 +1362,7 @@ async fn get_modpack_identifiers( } fn project_type_from_api_name(project_type: &str) -> ProjectType { - match project_type { - "resourcepack" => ProjectType::ResourcePack, - "shader" => ProjectType::ShaderPack, - "datapack" => ProjectType::DataPack, - _ => ProjectType::Mod, - } + ProjectType::from_name(project_type).unwrap_or(ProjectType::Mod) } fn sort_content_items(items: &mut [ContentItem]) { diff --git a/packages/app-lib/src/state/instances/commands/mod.rs b/packages/app-lib/src/state/instances/commands/mod.rs index bba272e0b..ff79b14d2 100644 --- a/packages/app-lib/src/state/instances/commands/mod.rs +++ b/packages/app-lib/src/state/instances/commands/mod.rs @@ -41,3 +41,9 @@ mod check_content_updates; mod apply_content_update; pub(crate) use self::apply_content_update::*; + +mod shared_instance; +pub(crate) use self::shared_instance::{ + attach_shared_instance, clear_shared_instance, mark_shared_instance_stale, + quarantine_shared_instance, set_shared_instance_sync_status, +}; diff --git a/packages/app-lib/src/state/instances/commands/shared_instance.rs b/packages/app-lib/src/state/instances/commands/shared_instance.rs new file mode 100644 index 000000000..d619b68f8 --- /dev/null +++ b/packages/app-lib/src/state/instances/commands/shared_instance.rs @@ -0,0 +1,184 @@ +use crate::state::instances::adapters::sqlite::{content_rows, instance_rows}; +use crate::state::instances::{ + ContentSetRemoteRef, ContentSetRemoteRefType, ContentSetSyncProvider, + ContentSetSyncState, ContentSetSyncStatus, InstanceLink, + SharedInstanceAttachment, SharedInstanceAttachmentInput, + SharedInstanceRole, +}; +use chrono::Utc; +use sqlx::SqlitePool; + +pub(crate) async fn attach_shared_instance( + instance_id: &str, + input: SharedInstanceAttachmentInput, + pool: &SqlitePool, +) -> crate::Result<()> { + let metadata = + instance_rows::get_instance_metadata_by_id(instance_id, pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let content_set_id = metadata.applied_content_set.id; + let attachment = SharedInstanceAttachment::from(input); + let sync_state = + shared_sync_state(&content_set_id, &attachment, Some(Utc::now())); + + let mut tx = pool.begin().await?; + instance_rows::set_shared_instance_attachment( + instance_id, + Some(&attachment), + &mut tx, + ) + .await?; + content_rows::upsert_content_set_remote_ref( + &ContentSetRemoteRef { + content_set_id: content_set_id.clone(), + ref_type: ContentSetRemoteRefType::SharedContentSet, + ref_id: attachment.id.clone(), + }, + &mut tx, + ) + .await?; + content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?; + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn clear_shared_instance( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + detach_shared_instance(instance_id, false, pool).await +} + +pub(crate) async fn quarantine_shared_instance( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + detach_shared_instance(instance_id, true, pool).await +} + +async fn detach_shared_instance( + instance_id: &str, + quarantine: bool, + pool: &SqlitePool, +) -> crate::Result<()> { + let Some(metadata) = + instance_rows::get_instance_metadata_by_id(instance_id, pool).await? + else { + return Ok(()); + }; + let content_set_id = metadata.applied_content_set.id; + let retained_modpack_link = match metadata.link { + InstanceLink::SharedInstance { + modpack_project_id: Some(project_id), + modpack_version_id: Some(version_id), + } => Some(InstanceLink::ModrinthModpack { + project_id, + version_id, + }), + _ => None, + }; + + let mut tx = pool.begin().await?; + instance_rows::set_shared_instance_attachment(instance_id, None, &mut tx) + .await?; + if quarantine { + instance_rows::set_instance_quarantined(instance_id, true, &mut tx) + .await?; + } + if let Some(link) = retained_modpack_link.as_ref() { + instance_rows::upsert_instance_link(instance_id, link, &mut tx).await?; + } + content_rows::delete_content_set_remote_ref( + &content_set_id, + ContentSetRemoteRefType::SharedContentSet, + &mut tx, + ) + .await?; + content_rows::delete_content_set_sync_state(&content_set_id, &mut tx) + .await?; + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn set_shared_instance_sync_status( + instance_id: &str, + status: ContentSetSyncStatus, + applied_version: Option, + latest_version: Option, + pool: &SqlitePool, +) -> crate::Result<()> { + let metadata = + instance_rows::get_instance_metadata_by_id(instance_id, pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(mut attachment) = metadata.shared_instance else { + return Ok(()); + }; + + attachment.status = status; + attachment.applied_version = applied_version; + attachment.latest_version = latest_version; + + let sync_state = shared_sync_state( + &metadata.applied_content_set.id, + &attachment, + Some(Utc::now()), + ); + let mut tx = pool.begin().await?; + content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?; + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn mark_shared_instance_stale( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + let Some(metadata) = + instance_rows::get_instance_metadata_by_id(instance_id, pool).await? + else { + return Ok(()); + }; + let Some(attachment) = metadata.shared_instance else { + return Ok(()); + }; + if attachment.role != SharedInstanceRole::Owner { + return Ok(()); + } + + set_shared_instance_sync_status( + instance_id, + ContentSetSyncStatus::Stale, + attachment.applied_version, + attachment.latest_version, + pool, + ) + .await +} + +fn shared_sync_state( + content_set_id: &str, + attachment: &SharedInstanceAttachment, + checked_at: Option>, +) -> ContentSetSyncState { + ContentSetSyncState { + content_set_id: content_set_id.to_string(), + provider: ContentSetSyncProvider::SharedInstance, + applied_update_id: attachment + .applied_version + .map(|value| value.to_string()), + latest_available_update_id: attachment + .latest_version + .map(|value| value.to_string()), + checked_at, + status: attachment.status, + } +} diff --git a/packages/app-lib/src/state/instances/commands/sync_content_files.rs b/packages/app-lib/src/state/instances/commands/sync_content_files.rs index 79af5ab59..a3b6ae915 100644 --- a/packages/app-lib/src/state/instances/commands/sync_content_files.rs +++ b/packages/app-lib/src/state/instances/commands/sync_content_files.rs @@ -75,6 +75,7 @@ pub(crate) async fn sync_instance_content_files( let now = Utc::now(); let mut files = Vec::new(); let mut present_without_hash_ids = Vec::new(); + let mut restored_without_hash = false; for file in scanned { let hash_key = file.hash_cache_key.trim_end_matches(".disabled"); @@ -82,6 +83,7 @@ pub(crate) async fn sync_instance_content_files( let Some(hash) = hashes_by_key.get(hash_key) else { if let Some(existing_file) = existing_file { present_without_hash_ids.push(existing_file.id.clone()); + restored_without_hash |= existing_file.missing; } continue; }; @@ -102,6 +104,19 @@ pub(crate) async fn sync_instance_content_files( }); } + let content_changed = !missing_file_ids.is_empty() + || restored_without_hash + || files.iter().any(|file| { + existing_files_by_path.get(&file.relative_path).is_none_or( + |existing| { + existing.missing + || existing.enabled != file.enabled + || existing.sha1 != file.sha1 + || existing.size != file.size + }, + ) + }); + let mut tx = state.pool.begin().await?; for file_id in missing_file_ids { sqlite::content_rows::set_instance_file_missing( @@ -129,6 +144,10 @@ pub(crate) async fn sync_instance_content_files( tx.commit().await?; + if content_changed { + super::mark_shared_instance_stale(&instance.id, &state.pool).await?; + } + Ok(stored_files) } diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs index 507d92092..6a0838ab6 100644 --- a/packages/app-lib/src/state/instances/content.rs +++ b/packages/app-lib/src/state/instances/content.rs @@ -1,3 +1,4 @@ +use super::ContentSourceKind; use crate::state::{Project, ProjectType, Version}; use serde::{Deserialize, Serialize}; @@ -15,6 +16,7 @@ pub struct ContentItem { pub has_update: bool, pub update_version_id: Option, pub date_added: Option, + pub source_kind: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/packages/app-lib/src/state/instances/mod.rs b/packages/app-lib/src/state/instances/mod.rs index 9426b01d5..4a9195c1f 100644 --- a/packages/app-lib/src/state/instances/mod.rs +++ b/packages/app-lib/src/state/instances/mod.rs @@ -10,6 +10,10 @@ pub use self::commands::{ AppliedContentSetPatch, CreateInstance, EditInstance, InstanceLaunchOverridesPatch, InstanceMetadata, }; +pub(crate) use self::commands::{ + attach_shared_instance, clear_shared_instance, mark_shared_instance_stale, + quarantine_shared_instance, set_shared_instance_sync_status, +}; pub(crate) use self::commands::{ create_instance, edit_instance, get_instance, get_instances_metadata, list_instances, refresh_all_instances, remove_instance, diff --git a/packages/app-lib/src/state/instances/model/content_set.rs b/packages/app-lib/src/state/instances/model/content_set.rs index 7fb62ea09..24e6f826d 100644 --- a/packages/app-lib/src/state/instances/model/content_set.rs +++ b/packages/app-lib/src/state/instances/model/content_set.rs @@ -16,6 +16,15 @@ pub enum ContentSourceKind { } impl ContentSourceKind { + pub fn is_shared_instance_managed(self) -> bool { + matches!( + self, + Self::SharedInstance + | Self::ModrinthModpack + | Self::ImportedModpack + ) + } + pub fn as_str(self) -> &'static str { match self { Self::Local => "local", diff --git a/packages/app-lib/src/state/instances/model/link.rs b/packages/app-lib/src/state/instances/model/link.rs index e85ed0860..0ae8e26e2 100644 --- a/packages/app-lib/src/state/instances/model/link.rs +++ b/packages/app-lib/src/state/instances/model/link.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use super::ContentSetSyncStatus; + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InstanceLink { @@ -32,7 +34,85 @@ pub enum InstanceLink { version_number: Option, filename: Option, }, + /// Modpack provenance installed by a shared instance. Remote membership, + /// manager identity, and synchronization state belong to + /// [`SharedInstanceAttachment`]. SharedInstance { - shared_instance_id: Uuid, + modpack_project_id: Option, + modpack_version_id: Option, }, } + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SharedInstanceRole { + Owner, + Member, +} + +impl SharedInstanceRole { + pub fn is_member(self) -> bool { + self == Self::Member + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Owner => "owner", + Self::Member => "member", + } + } + + pub fn from_stored_str(value: &str) -> crate::Result { + match value { + "owner" => Ok(Self::Owner), + "member" => Ok(Self::Member), + other => Err(super::unknown_value("shared instance role", other)), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +/// The remote shared-instance relationship for a local instance. This is +/// independent from the optional modpack provenance stored in [`InstanceLink`]. +pub struct SharedInstanceAttachment { + pub id: String, + pub role: SharedInstanceRole, + pub manager_id: Option, + #[serde(default)] + pub server_manager_name: Option, + #[serde(default)] + pub server_manager_icon_url: Option, + pub linked_user_id: Option, + pub status: ContentSetSyncStatus, + pub applied_version: Option, + pub latest_version: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct SharedInstanceAttachmentInput { + pub id: String, + pub role: SharedInstanceRole, + pub manager_id: Option, + pub server_manager_name: Option, + pub server_manager_icon_url: Option, + pub linked_user_id: Option, + pub status: ContentSetSyncStatus, + pub applied_version: Option, + pub latest_version: Option, +} + +impl From for SharedInstanceAttachment { + fn from(input: SharedInstanceAttachmentInput) -> Self { + Self { + id: input.id, + role: input.role, + manager_id: input.manager_id, + server_manager_name: input.server_manager_name, + server_manager_icon_url: input.server_manager_icon_url, + linked_user_id: input.linked_user_id, + status: input.status, + applied_version: input.applied_version, + latest_version: input.latest_version, + } + } +} diff --git a/packages/app-lib/src/state/instances/watcher.rs b/packages/app-lib/src/state/instances/watcher.rs index 08647a5dc..b7e6dd86c 100644 --- a/packages/app-lib/src/state/instances/watcher.rs +++ b/packages/app-lib/src/state/instances/watcher.rs @@ -1,4 +1,5 @@ use crate::State; +use crate::api::instance::CONFIG_DIRECTORY; use crate::event::InstancePayloadType; use crate::event::emit::{emit_instance, emit_warning}; use crate::state::{ @@ -128,17 +129,41 @@ pub async fn init_watcher() -> crate::Result { Some(InstancePayloadType::WorldUpdated { world, }) - } else if first_file_name - .as_ref() - .is_none_or(|x| *x != "saves") - { + } else if first_file_name.as_ref().is_none_or( + |x| *x != "saves" && *x != CONFIG_DIRECTORY, + ) { Some(InstancePayloadType::Synced) } else { None }; if let Some(event) = event { let emit_instance_id = instance_id.clone(); + let mark_shared_stale = first_file_name + .as_ref() + .is_some_and(|name| { + ProjectType::iterator().any( + |project_type| { + *name + == project_type + .get_folder() + }, + ) + }); tokio::spawn(async move { + if mark_shared_stale + && let Ok(state) = + State::get().await + && let Err(error) = + crate::state::mark_shared_instance_stale( + &emit_instance_id, + &state.pool, + ) + .await + { + tracing::error!( + "Failed to mark shared instance stale after filesystem sync: {error}" + ); + } let _ = emit_instance( &emit_instance_id, event, @@ -194,10 +219,11 @@ pub(crate) async fn watch_instance_folder( } let mut to_watch = Vec::new(); - for sub_path in ProjectType::iterator() - .map(|x| x.get_folder()) - .chain(["crash-reports", "saves"]) - { + for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([ + "crash-reports", + "saves", + CONFIG_DIRECTORY, + ]) { let full_path = full_instance_path.join(sub_path); let meta = tokio::fs::symlink_metadata(&full_path).await; diff --git a/packages/app-lib/src/state/mr_auth.rs b/packages/app-lib/src/state/mr_auth.rs index 09b735ea5..54276bf56 100644 --- a/packages/app-lib/src/state/mr_auth.rs +++ b/packages/app-lib/src/state/mr_auth.rs @@ -195,6 +195,10 @@ pub const fn get_login_url() -> &'static str { concat!(env!("MODRINTH_URL"), "auth/sign-in") } +pub const fn get_signup_url() -> &'static str { + concat!(env!("MODRINTH_URL"), "auth/sign-up") +} + pub async fn finish_login_flow( code: &str, semaphore: &FetchSemaphore, diff --git a/packages/app-lib/src/util/fetch.rs b/packages/app-lib/src/util/fetch.rs index 0662a3d5c..fbca82414 100644 --- a/packages/app-lib/src/util/fetch.rs +++ b/packages/app-lib/src/util/fetch.rs @@ -915,13 +915,10 @@ pub async fn write_cached_icon( bytes: Bytes, semaphore: &IoSemaphore, ) -> crate::Result { - let extension = Path::new(&icon_path).extension().and_then(OsStr::to_str); let hash = sha1_async(bytes.clone()).await?; - let path = cache_dir.join("icons").join(if let Some(ext) = extension { - format!("{hash}.{ext}") - } else { - hash - }); + let path = cache_dir + .join("icons") + .join(cached_icon_file_name(icon_path, &hash)); write(&path, &bytes, semaphore).await?; @@ -929,6 +926,14 @@ pub async fn write_cached_icon( Ok(path) } +fn cached_icon_file_name(icon_path: &str, hash: &str) -> String { + let path = icon_path.split(['?', '#']).next().unwrap_or(icon_path); + match Path::new(path).extension().and_then(OsStr::to_str) { + Some(extension) => format!("{hash}.{extension}"), + None => hash.to_string(), + } +} + pub async fn sha1_async(bytes: Bytes) -> crate::Result { let hash = tokio::task::spawn_blocking(move || { sha1_smol::Sha1::from(bytes).hexdigest() diff --git a/packages/assets/branding/illustrations/invite-bg.webp b/packages/assets/branding/illustrations/invite-bg.webp new file mode 100644 index 000000000..3502dd8d3 Binary files /dev/null and b/packages/assets/branding/illustrations/invite-bg.webp differ diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 4b499d822..f64502021 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -118,6 +118,7 @@ import _EyeOffIcon from './icons/eye-off.svg?component' import _FileIcon from './icons/file.svg?component' import _FileArchiveIcon from './icons/file-archive.svg?component' import _FileCodeIcon from './icons/file-code.svg?component' +import _FileCogIcon from './icons/file-cog.svg?component' import _FileImageIcon from './icons/file-image.svg?component' import _FilePlusIcon from './icons/file-plus.svg?component' import _FileTextIcon from './icons/file-text.svg?component' @@ -549,6 +550,7 @@ export const EyeOffIcon = _EyeOffIcon export const FileIcon = _FileIcon export const FileArchiveIcon = _FileArchiveIcon export const FileCodeIcon = _FileCodeIcon +export const FileCogIcon = _FileCogIcon export const FileImageIcon = _FileImageIcon export const FilePlusIcon = _FilePlusIcon export const FileTextIcon = _FileTextIcon diff --git a/packages/assets/icons/file-cog.svg b/packages/assets/icons/file-cog.svg new file mode 100644 index 000000000..b2ed0bd15 --- /dev/null +++ b/packages/assets/icons/file-cog.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + diff --git a/packages/assets/index.ts b/packages/assets/index.ts index b7ab443c7..1eb049970 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -11,6 +11,7 @@ import './omorphia.scss' import _FourOhFourNotFound from './branding/404.svg?component' // Branding import _BrowserWindowSuccessIllustration from './branding/illustrations/browser-window-success.svg?component' +import _InviteBackgroundIllustration from './branding/illustrations/invite-bg.webp?url' import _ModrinthIcon from './branding/logo.svg?component' import _ModrinthPlusIcon from './branding/modrinth-plus.svg?component' import _AngryRinthbot from './branding/rinthbot/angry.webp' @@ -84,6 +85,7 @@ import _NoTasksIllustration from './illustrations/no-tasks.svg?component' export const ModrinthIcon = _ModrinthIcon export const BrowserWindowSuccessIllustration = _BrowserWindowSuccessIllustration +export const InviteBackgroundIllustration = _InviteBackgroundIllustration export const FourOhFourNotFound = _FourOhFourNotFound export const ModrinthPlusIcon = _ModrinthPlusIcon export const AngryRinthbot = _AngryRinthbot diff --git a/packages/moderation/src/types/reports.ts b/packages/moderation/src/types/reports.ts index 249e457f8..e83b72570 100644 --- a/packages/moderation/src/types/reports.ts +++ b/packages/moderation/src/types/reports.ts @@ -1,5 +1,5 @@ -import type { Labrinth } from '@modrinth/api-client' -import type { Report, Thread, User, Version } from '@modrinth/utils' +import type { Labrinth, SharedInstances } from '@modrinth/api-client' +import type { Thread, User, Version } from '@modrinth/utils' export interface OwnershipTarget { name: string @@ -8,11 +8,12 @@ export interface OwnershipTarget { type: 'user' | 'organization' } -export interface ExtendedReport extends Report { +export interface ExtendedReport extends Labrinth.Reports.v3.Report { thread: Thread reporter_user: User project?: Labrinth.Projects.v2.Project user?: User version?: Version target?: OwnershipTarget + shared_instance?: SharedInstances.Instances.v1.Instance } diff --git a/packages/path-util/src/lib.rs b/packages/path-util/src/lib.rs index eafa733c0..2d8605d88 100644 --- a/packages/path-util/src/lib.rs +++ b/packages/path-util/src/lib.rs @@ -13,6 +13,20 @@ use typed_path::{ #[repr(transparent)] pub struct SafeRelativeUtf8UnixPathBuf(Utf8UnixPathBuf); +pub fn is_safe_file_name(file_name: &str) -> bool { + if file_name.contains('/') || file_name.contains('\\') { + return false; + } + + SafeRelativeUtf8UnixPathBuf::try_from(file_name.to_string()).is_ok_and( + |path| { + path.components() + .exactly_one() + .is_ok_and(|component| component.is_normal()) + }, + ) +} + impl<'de> Deserialize<'de> for SafeRelativeUtf8UnixPathBuf { fn deserialize>( deserializer: D, @@ -146,3 +160,32 @@ fn safe_relative_path_deserialization_contract() { .expect_err("Path should be considered invalid"); } } + +#[test] +fn safe_file_name_contract() { + let valid_file_names = [ + "file.txt", + "file.name.with.dots.tar.gz", + "file..name.jar", + "123_456-789.file", + ]; + for file_name in valid_file_names { + assert!(is_safe_file_name(file_name)); + } + + let invalid_file_names = [ + "", + ".", + "..", + "../file.txt", + "directory/file.txt", + r"..\file.txt", + r"directory\file.txt", + "C:file.txt", + "C:/file.txt", + "NUL.txt", + ]; + for file_name in invalid_file_names { + assert!(!is_safe_file_name(file_name)); + } +} diff --git a/packages/ui/src/components/base/Admonition.vue b/packages/ui/src/components/base/Admonition.vue index 38c09af89..26a3bfef6 100644 --- a/packages/ui/src/components/base/Admonition.vue +++ b/packages/ui/src/components/base/Admonition.vue @@ -9,25 +9,47 @@ -
+
- {{ header }} - - - {{ relativeTimeLabel }} - + {{ header }} + + + {{ relativeTimeLabel }} + +
+
+ {{ body }} +
-
- {{ body }} +
+
-
+
@@ -80,9 +102,10 @@ import ButtonStyled from './ButtonStyled.vue' const props = withDefaults( defineProps<{ - type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning' + type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning' | 'neutral' header?: string body?: string + inlineActions?: boolean showActionsUnderneath?: boolean dismissible?: boolean progress?: number @@ -95,6 +118,7 @@ const props = withDefaults( type: 'info', header: '', body: '', + inlineActions: false, showActionsUnderneath: false, dismissible: false, progress: undefined, @@ -143,6 +167,7 @@ const typeClasses = { critical: 'border-brand-red bg-bg-red', success: 'border-brand-green bg-bg-green', moderation: 'border-brand-orange bg-bg-orange', + neutral: 'border-surface-4 bg-surface-3', } const iconClasses = { @@ -152,6 +177,7 @@ const iconClasses = { critical: 'text-brand-red', success: 'text-brand-green', moderation: 'text-brand-orange', + neutral: 'text-secondary', } const buttonColors = { @@ -161,6 +187,7 @@ const buttonColors = { critical: 'red', success: 'green', moderation: 'orange', + neutral: 'standard', } as const const progressTrackClasses = { @@ -170,6 +197,7 @@ const progressTrackClasses = { critical: 'bg-brand-red/20', success: 'bg-brand-green/20', moderation: 'bg-brand-orange/20', + neutral: 'bg-surface-4', } const progressFillClasses = { @@ -181,10 +209,15 @@ const progressFillClasses = { blue: 'bg-brand-blue', green: 'bg-brand-green', red: 'bg-brand-red', + neutral: 'bg-surface-5', }