From 9404d46782978e11f27a44b100546c278f8c8db0 Mon Sep 17 00:00:00 2001
From: "Calum H."
Date: Mon, 8 Jun 2026 18:10:59 +0100
Subject: [PATCH] feat: release channels instance setting (#6252)
* feat: rough release channels impl draft
* feat: move to bottom + lint
* fix: invalidate content queries on channel change
* fix: change to chips
* fix: lint
* fix: copy
---
.../ui/instance_settings/GeneralSettings.vue | 105 +++++++++
apps/app-frontend/src/helpers/types.d.ts | 3 +
.../app-frontend/src/locales/en-US/index.json | 24 ++
apps/app-frontend/src/pages/instance/Mods.vue | 9 +
apps/app/src/api/profile.rs | 6 +
...63a1388ef538e4b80680bce4b668b182b4f42.json | 12 +
...8f4ec2e248f751f98140f77bea4f9d5971ef1.json | 12 -
...b7e57590dc75b721dd823d00572f428d0bc5.json} | 52 +++--
...34e0a64101ead329bcc88e55e31b32578a97.json} | 52 +++--
...60529120000_profile-prerelease-updates.sql | 2 +
packages/app-lib/src/api/mod.rs | 1 +
packages/app-lib/src/api/profile/create.rs | 5 +-
packages/app-lib/src/state/cache.rs | 191 +++++++++++++---
.../app-lib/src/state/instances/content.rs | 50 ++--
.../app-lib/src/state/legacy_converter.rs | 6 +-
packages/app-lib/src/state/profiles.rs | 215 +++++++++++++-----
.../src/layouts/shared/content-tab/index.ts | 1 +
.../content-tab/utils/update-channels.ts | 77 +++++++
18 files changed, 669 insertions(+), 154 deletions(-)
create mode 100644 packages/app-lib/.sqlx/query-22202dacf6b5bade90c909cb4a663a1388ef538e4b80680bce4b668b182b4f42.json
delete mode 100644 packages/app-lib/.sqlx/query-27283e20fc86c941c7d6d09259d8f4ec2e248f751f98140f77bea4f9d5971ef1.json
rename packages/app-lib/.sqlx/{query-6a434cc55635b6e325e9e5f06d21b787af281db4402c8ff45fe6a77b5be6c929.json => query-be21bfd7c36fd8c69dfffe0299eeb7e57590dc75b721dd823d00572f428d0bc5.json} (81%)
rename packages/app-lib/.sqlx/{query-c108849d77c7627d6a11d5be34984938c41283e6091a1301fc3ca0b355ffcfa9.json => query-de1887a95766303d16ddcad7fe7f34e0a64101ead329bcc88e55e31b32578a97.json} (81%)
create mode 100644 packages/app-lib/migrations/20260529120000_profile-prerelease-updates.sql
create mode 100644 packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts
diff --git a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue
index 979ca33a3..e30bead72 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue
+++ b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue
@@ -4,12 +4,14 @@ import {
Avatar,
ButtonStyled,
Checkbox,
+ Chips,
defineMessages,
injectNotificationManager,
OverflowMenu,
StyledInput,
useVIntl,
} from '@modrinth/ui'
+import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, type Ref, ref, watch } from 'vue'
@@ -25,14 +27,22 @@ import type { GameInstance } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const router = useRouter()
+const queryClient = useQueryClient()
const deleteConfirmModal = ref()
const { instance } = injectInstanceSettings()
+type ReleaseChannel = GameInstance['preferred_update_channel']
+const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
const title = ref(instance.value.name)
const icon: Ref = ref(instance.value.icon_path)
const groups = ref([...instance.value.groups])
+const savingReleaseChannel = ref(false)
+const selectedReleaseChannel = ref(instance.value.preferred_update_channel)
+const releaseChannelDisabledItems = computed(() =>
+ savingReleaseChannel.value ? [...releaseChannelOptions] : [],
+)
const newCategoryInput = ref('')
@@ -51,6 +61,52 @@ const availableGroups = computed(() => [
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
])
+function formatReleaseChannelLabel(channel: ReleaseChannel) {
+ switch (channel) {
+ case 'release':
+ return formatMessage(messages.updateChannelRelease)
+ case 'beta':
+ return formatMessage(messages.updateChannelBeta)
+ case 'alpha':
+ return formatMessage(messages.updateChannelAlpha)
+ }
+}
+
+function formatReleaseChannelDescription(channel: ReleaseChannel) {
+ switch (channel) {
+ case 'release':
+ return formatMessage(messages.updateChannelReleaseDescription)
+ case 'beta':
+ return formatMessage(messages.updateChannelBetaDescription)
+ case 'alpha':
+ return formatMessage(messages.updateChannelAlphaDescription)
+ }
+}
+
+watch(
+ () => [instance.value.path, instance.value.preferred_update_channel] as const,
+ () => {
+ if (!savingReleaseChannel.value) {
+ selectedReleaseChannel.value = instance.value.preferred_update_channel
+ }
+ },
+)
+
+watch(selectedReleaseChannel, async (channel, previousChannel) => {
+ const previousReleaseChannel = previousChannel ?? instance.value.preferred_update_channel
+ if (channel === instance.value.preferred_update_channel) return
+
+ savingReleaseChannel.value = true
+ const profilePath = instance.value.path
+ await edit(profilePath, { preferred_update_channel: channel })
+ .then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', profilePath] }))
+ .catch((error) => {
+ selectedReleaseChannel.value = previousReleaseChannel
+ handleError(error)
+ })
+ savingReleaseChannel.value = false
+})
+
async function resetIcon() {
icon.value = undefined
await edit_icon(instance.value.path, null).catch(handleError)
@@ -175,6 +231,38 @@ const messages = defineMessages({
id: 'instance.settings.tabs.general.duplicate-button',
defaultMessage: 'Duplicate',
},
+ updateChannel: {
+ id: 'instance.settings.tabs.general.update-channel',
+ defaultMessage: 'Update channel',
+ },
+ updateChannelReleaseDescription: {
+ id: 'instance.settings.tabs.general.update-channel.release.description',
+ defaultMessage: 'Only release versions will be shown as available updates.',
+ },
+ updateChannelBetaDescription: {
+ id: 'instance.settings.tabs.general.update-channel.beta.description',
+ defaultMessage: 'Release and beta versions will be shown as available updates.',
+ },
+ updateChannelAlphaDescription: {
+ id: 'instance.settings.tabs.general.update-channel.alpha.description',
+ defaultMessage: 'Release, beta, and alpha versions will be shown as available updates.',
+ },
+ updateChannelRelease: {
+ id: 'instance.settings.tabs.general.update-channel.release',
+ defaultMessage: 'Release',
+ },
+ updateChannelBeta: {
+ id: 'instance.settings.tabs.general.update-channel.beta',
+ defaultMessage: 'Beta',
+ },
+ updateChannelAlpha: {
+ id: 'instance.settings.tabs.general.update-channel.alpha',
+ defaultMessage: 'Alpha',
+ },
+ selectUpdateChannelAriaLabel: {
+ id: 'instance.settings.tabs.general.update-channel.select',
+ defaultMessage: 'Select update channel',
+ },
deleteInstance: {
id: 'instance.settings.tabs.general.delete',
defaultMessage: 'Delete instance',
@@ -304,6 +392,23 @@ const messages = defineMessages({
+
+
+ {{ formatMessage(messages.updateChannel) }}
+
+
+
+ {{ formatReleaseChannelDescription(selectedReleaseChannel) }}
+
+
+
{{ formatMessage(messages.deleteInstance) }}
diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts
index 7c5f1d25e..143997702 100644
--- a/apps/app-frontend/src/helpers/types.d.ts
+++ b/apps/app-frontend/src/helpers/types.d.ts
@@ -14,6 +14,7 @@ export type GameInstance = {
groups: string[]
linked_data?: LinkedData
+ preferred_update_channel: ReleaseChannel
created: Date
modified: Date
@@ -46,6 +47,8 @@ type LinkedData = {
locked: boolean
}
+type ReleaseChannel = 'release' | 'beta' | 'alpha'
+
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 052e8fd5a..251cba9a9 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -713,6 +713,30 @@
"instance.settings.tabs.general.name": {
"message": "Name"
},
+ "instance.settings.tabs.general.update-channel": {
+ "message": "Update channel"
+ },
+ "instance.settings.tabs.general.update-channel.alpha": {
+ "message": "Alpha"
+ },
+ "instance.settings.tabs.general.update-channel.alpha.description": {
+ "message": "Release, beta, and alpha versions will be shown as available updates."
+ },
+ "instance.settings.tabs.general.update-channel.beta": {
+ "message": "Beta"
+ },
+ "instance.settings.tabs.general.update-channel.beta.description": {
+ "message": "Release and beta versions will be shown as available updates."
+ },
+ "instance.settings.tabs.general.update-channel.release": {
+ "message": "Release"
+ },
+ "instance.settings.tabs.general.update-channel.release.description": {
+ "message": "Only release versions will be shown as available updates."
+ },
+ "instance.settings.tabs.general.update-channel.select": {
+ "message": "Select update channel"
+ },
"instance.settings.tabs.hooks": {
"message": "Launch hooks"
},
diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue
index f55f944cc..a323f939c 100644
--- a/apps/app-frontend/src/pages/instance/Mods.vue
+++ b/apps/app-frontend/src/pages/instance/Mods.vue
@@ -1227,6 +1227,15 @@ watch(
},
)
+watch(
+ () => props.instance?.preferred_update_channel,
+ async (newValue, oldValue) => {
+ if (newValue !== oldValue) {
+ await initProjects('must_revalidate')
+ }
+ },
+)
+
onUnmounted(() => {
isUnmounted = true
removeBeforeEach()
diff --git a/apps/app/src/api/profile.rs b/apps/app/src/api/profile.rs
index d33d3a3f2..e35a3675c 100644
--- a/apps/app/src/api/profile.rs
+++ b/apps/app/src/api/profile.rs
@@ -385,6 +385,7 @@ pub struct EditProfile {
with = "serde_with::rust::double_option"
)]
pub linked_data: Option