diff --git a/.github/workflows/theseus-build.yml b/.github/workflows/theseus-build.yml
index d4903a4cbbd..01afd892f1a 100644
--- a/.github/workflows/theseus-build.yml
+++ b/.github/workflows/theseus-build.yml
@@ -16,6 +16,11 @@ on:
- 'packages/utils/**'
workflow_dispatch:
inputs:
+ sign-macos-binaries:
+ description: Sign macOS binaries
+ type: boolean
+ default: true
+ required: false
sign-windows-binaries:
description: Sign Windows binaries
type: boolean
@@ -170,9 +175,9 @@ jobs:
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
fi
- - name: Build macOS app
+ - name: Build signed macOS app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
- if: contains(matrix.artifact-target-name, 'darwin')
+ if: contains(matrix.artifact-target-name, 'darwin') && (github.event_name != 'workflow_dispatch' || inputs.sign-macos-binaries)
env:
TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
@@ -185,6 +190,14 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+ - name: Build unsigned macOS app
+ run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
+ if: contains(matrix.artifact-target-name, 'darwin') && github.event_name == 'workflow_dispatch' && !inputs.sign-macos-binaries
+ env:
+ TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+
- name: Build Linux app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
if: contains(matrix.artifact-target-name, 'linux')
diff --git a/Cargo.lock b/Cargo.lock
index e5f309744cf..59ba0ac5d2a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -13808,6 +13808,7 @@ dependencies = [
"regex",
"reqwest 0.13.5",
"rgb",
+ "rustls 0.23.46",
"same-file",
"serde",
"serde-binhum",
diff --git a/Cargo.toml b/Cargo.toml
index 8f86595c711..e784e9a4cca 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -182,7 +182,7 @@ rust-s3 = { version = "0.38.0", default-features = false, features = [
"tags",
"tokio-rustls-tls",
] }
-rustls = "0.23.46"
+rustls = { version = "0.23.46", default-features = false }
rustrict = { version = "0.7.39", default-features = false, features = ["censor"] }
rusty-money = "0.5.1"
same-file = "1.0.6"
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 50a9ae60f09..457c884cc8c 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -27,6 +27,8 @@ import {
commonSettingsMessages,
ContentInstallModal,
ContentUpdaterModal,
+ createServerInviteHandoff,
+ createServerOnboardingFlow,
CreationFlowModal,
defineMessages,
I18nDebugPanel,
@@ -35,12 +37,17 @@ import {
NotificationPanel,
PopupNotificationPanel,
provideFileDownload,
+ provideIconCache,
provideLoadingState,
provideModalBehavior,
provideModrinthClient,
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
+ provideServerInviteHandoff,
+ provideServerOnboardingFlow,
+ provideServerPlay,
+ ServerOnboardingModal,
TeleportOverflowMenu,
TooltipDirective,
useDebugLogger,
@@ -90,6 +97,12 @@ import { useInstanceMetadataRefresh } from '@/composables/use-instance-metadata-
import { useQuickInstanceLimit } from '@/composables/use-quick-instance-limit.ts'
import { isDarkTheme, useTheme } from '@/composables/use-theme.ts'
import { config } from '@/config'
+import ServerPlayHandler from '@/features/hosting/play-handler.vue'
+import { prefetchHostingData } from '@/features/hosting/prefetch'
+import {
+ createInstanceLaunchState,
+ provideInstanceLaunchState,
+} from '@/features/instances/launch-state'
import { provideMinecraftAccounts } from '@/features/minecraft-accounts/context'
import AppSettingsModal from '@/features/settings/app-settings-modal/index.vue'
import { provideSettingsActions } from '@/features/settings/settings-actions'
@@ -160,6 +173,7 @@ import {
screenshotKeys,
} from '@/pages/instance/query-options'
import { createFileDownload } from '@/platform/adapters/file-download'
+import { createIconCache } from '@/platform/adapters/icon-cache'
import { createAppClient } from '@/platform/modrinth-client'
import {
appUpdateState,
@@ -286,7 +300,20 @@ useAppEvent('ads_consent_required', handleAdsConsentRequired, appEvents)
const tauriApiClient = createAppClient()
provideModrinthClient(tauriApiClient)
+provideServerOnboardingFlow(createServerOnboardingFlow())
+provideServerInviteHandoff(createServerInviteHandoff())
provideFileDownload(createFileDownload(tauriApiClient, trackExternalFileDownload))
+const iconCache = createIconCache()
+provideIconCache(iconCache)
+provideInstanceLaunchState(createInstanceLaunchState())
+const serverPlayHandler = ref()
+const serverPlay = {
+ async play(target) {
+ if (!serverPlayHandler.value) throw new Error('Server play handler is not ready.')
+ await serverPlayHandler.value.play(target)
+ },
+}
+provideServerPlay(serverPlay)
const { data: authenticatedModrinthUser } = useQuery({
queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]),
queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(),
@@ -808,8 +835,14 @@ async function setupApp() {
}
const stateFailed = ref(false)
-const { loading, canNavigateBack, canNavigateForward, onSuspensePending, onSuspenseResolve } =
- useRouteLoading({ stateInitialized, stateFailed })
+const {
+ loading,
+ canNavigateBack,
+ canNavigateForward,
+ onboardingPageReady,
+ onSuspensePending,
+ onSuspenseResolve,
+} = useRouteLoading({ stateInitialized, stateFailed })
provideLoadingState(loading)
traceStartupStep('Initialize backend state', () => initialize_state(appEventChannel))
@@ -833,42 +866,19 @@ const handleClose = async () => {
const queryClient = useQueryClient()
-watch(stateInitialized, (ready) => {
- if (ready) {
- queryClient.prefetchQuery({
- queryKey: ['servers'],
- queryFn: async () => {
- const response = await tauriApiClient.archon.servers_v0.list({ limit: 100 })
- const hasMedalServers = response.servers.some((s) => s.is_medal)
- if (hasMedalServers) {
- const subscriptions = await tauriApiClient.labrinth.billing_internal.getSubscriptions()
- for (const server of response.servers) {
- if (server.is_medal) {
- const sub = subscriptions.find((s) => s.metadata?.id === server.server_id)
- if (sub) {
- server.medal_expires = new Date(
- new Date(sub.created).getTime() + 5 * 86400000,
- ).toISOString()
- }
- }
- }
- }
- return response
- },
- staleTime: 30_000,
- })
- queryClient.prefetchQuery({
- queryKey: ['billing', 'subscriptions'],
- queryFn: () => tauriApiClient.labrinth.billing_internal.getSubscriptions(),
- staleTime: 30_000,
- })
- queryClient.prefetchQuery({
- queryKey: ['billing', 'payments'],
- queryFn: () => tauriApiClient.labrinth.billing_internal.getPayments(),
- staleTime: 30_000,
- })
- }
-})
+watch(
+ () => stateInitialized.value && credentials.value?.session,
+ (session) => {
+ if (!session) return
+
+ void prefetchHostingData(
+ queryClient,
+ tauriApiClient,
+ iconCache,
+ () => credentials.value?.session === session,
+ )
+ },
+)
const error = useError()
const errorModal = ref()
@@ -1539,6 +1549,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
+ } else if (e.event === 'PlayHostingServer') {
+ await serverPlay.play({ serverId: e.server_id, worldId: e.world_id }).catch(handleError)
} else if (e.event === 'InstallSharedInstanceInvite') {
await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
} else if (e.event === 'InstallServer') {
@@ -2005,6 +2017,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@create="handleCreate"
@browse-modpacks="handleBrowseModpacks"
/>
+
+
diff --git a/apps/app-frontend/src/app/runtime/use-route-loading.ts b/apps/app-frontend/src/app/runtime/use-route-loading.ts
index b4af0504bc2..9e21984a0f6 100644
--- a/apps/app-frontend/src/app/runtime/use-route-loading.ts
+++ b/apps/app-frontend/src/app/runtime/use-route-loading.ts
@@ -17,6 +17,7 @@ export function useRouteLoading({ stateInitialized, stateFailed }: RouteLoadingO
const route = useRoute()
const canNavigateBack = ref(false)
const canNavigateForward = ref(false)
+ const onboardingPageReady = ref(true)
let initialLoadToken: symbol | null = null
let routerToken: symbol | null = null
let suspenseToken: symbol | null = null
@@ -83,6 +84,7 @@ export function useRouteLoading({ stateInitialized, stateFailed }: RouteLoadingO
})
function onSuspensePending() {
+ onboardingPageReady.value = false
debugStartup('Route Suspense pending', { route: route.path })
suspensePending = true
viewportScroll.onRoutePending()
@@ -91,6 +93,7 @@ export function useRouteLoading({ stateInitialized, stateFailed }: RouteLoadingO
}
function onSuspenseResolve() {
+ onboardingPageReady.value = true
debugStartup('Route Suspense resolved', { route: route.path })
viewportScroll.onRouteResolved()
if (suspenseToken) {
@@ -120,5 +123,12 @@ export function useRouteLoading({ stateInitialized, stateFailed }: RouteLoadingO
}
})
- return { loading, canNavigateBack, canNavigateForward, onSuspensePending, onSuspenseResolve }
+ return {
+ loading,
+ canNavigateBack,
+ canNavigateForward,
+ onboardingPageReady,
+ onSuspensePending,
+ onSuspenseResolve,
+ }
}
diff --git a/apps/app-frontend/src/components/ui/HostingUpdateRequired.vue b/apps/app-frontend/src/components/ui/HostingUpdateRequired.vue
index 465b8b8221c..5f94713dd4c 100644
--- a/apps/app-frontend/src/components/ui/HostingUpdateRequired.vue
+++ b/apps/app-frontend/src/components/ui/HostingUpdateRequired.vue
@@ -1,6 +1,6 @@
diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
index b99a6461781..d53a3418944 100644
--- a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
@@ -69,6 +69,7 @@ const diffs = computed(
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
+ fileNames: diff.configFileNames ?? undefined,
disabled: diff.disabled,
external: diff.type === 'added' && !diff.projectId && !!diff.fileName,
})) ?? [],
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
index e391474d311..4d0ea51af13 100644
--- 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
@@ -6,7 +6,7 @@
? formatMessage(messages.reportSharedInstance)
: formatMessage(messages.installToPlay)
"
- :closable="!submitLoading"
+ :closable="!submitLoading && !installing"
:on-hide="handleHide"
:max-width="reportMode ? '816px' : '544px'"
:width="reportMode ? '816px' : '544px'"
@@ -193,23 +193,23 @@
{{ formatMessage(messages.reviewedFiles) }}
-
@@ -56,7 +56,7 @@ import {
} from '@modrinth/ui'
import { computed, ref } from 'vue'
-import { methodLabels, type ShareRow } from './shared-instance-share-types'
+import { methodMessages, type ShareRow } from './shared-instance-share-types'
const props = defineProps<{ row: ShareRow | null; memberCount: number }>()
const emit = defineEmits<{ confirm: [row: ShareRow]; clear: [] }>()
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
index a9bebd01c25..c123661a264 100644
--- 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
@@ -1,22 +1,8 @@
-export type ShareMethod = 'direct' | 'link'
-export type MethodFilter = ShareMethod | 'all'
-export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
+export {
+ invitedPlayerMethodMessages as methodMessages,
+ normalizeInviteKey,
+ type InvitedPlayerMethod as ShareMethod,
+ type InvitedPlayerRow as ShareRow,
+} from '@modrinth/ui'
export const SHARED_INSTANCE_USER_LIMIT = 50
-
-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/shared-instance-context.ts b/apps/app-frontend/src/pages/instance/shared-instance-context.ts
index 163810122d2..2119321f955 100644
--- a/apps/app-frontend/src/pages/instance/shared-instance-context.ts
+++ b/apps/app-frontend/src/pages/instance/shared-instance-context.ts
@@ -1,18 +1,19 @@
+import { MinecraftServerIcon } from '@modrinth/assets'
import { createContext, injectAuth } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref, ref, watch } from 'vue'
import { useUserQuery } from '@/composables/users/use-user-query'
+import { injectInstanceLaunchState } from '@/features/instances/launch-state'
import {
getSharedInstanceUnavailableReason,
- install_get_shared_instance_update_preview,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
-import { can_current_user_use_shared_instances } from '@/helpers/instance'
+import { can_current_user_use_shared_instances, getInstanceIconUrl } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
-import { instanceKeys } from './query-options'
+import { instanceKeys, sharedInstanceUpdatePreviewQueryOptions } from './query-options'
export type SharedInstanceManager =
| {
@@ -35,6 +36,7 @@ export function createSharedInstanceContext(
) {
const auth = injectAuth()
const queryClient = useQueryClient()
+ const instanceLaunch = injectInstanceLaunchState()
const forcedUnavailableReason = ref(null)
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
@@ -62,7 +64,7 @@ export function createSharedInstanceContext(
return {
type: 'server',
name: attachment.server_manager_name,
- avatarUrl: attachment.server_manager_icon_url ?? undefined,
+ avatarUrl: getInstanceIconUrl(attachment.server_manager_icon_url) ?? MinecraftServerIcon,
tintBy: attachment.server_manager_name,
}
}
@@ -91,13 +93,10 @@ export function createSharedInstanceContext(
() => !auth.session_token.value || eligibilityQuery.data.value !== false,
)
- const updatePreviewQuery = useQuery({
- queryKey: computed(() =>
- instanceKeys.sharedUpdatePreview(instance.value?.id ?? '', auth.user.value?.id),
- ),
- queryFn: () => install_get_shared_instance_update_preview(instance.value!.id),
- enabled: computed(
- () =>
+ const updatePreviewQuery = useQuery(
+ computed(() => ({
+ ...sharedInstanceUpdatePreviewQueryOptions(instance.value?.id ?? '', auth.user.value?.id),
+ enabled:
!!instance.value?.id &&
instance.value.install_stage === 'installed' &&
!!instance.value.shared_instance &&
@@ -106,23 +105,28 @@ export function createSharedInstanceContext(
(auth.isReady?.value ?? true) &&
!!auth.session_token.value &&
!!auth.user.value?.id,
- ),
- retry: false,
- staleTime: 30_000,
- refetchOnWindowFocus: false,
- })
+ })),
+ )
watch(updatePreviewQuery.data, (preview) => {
if (preview !== undefined) forcedUnavailableReason.value = null
})
- watch(updatePreviewQuery.error, (error) => {
- if (!error) return
- if (isSharedInstanceUnavailableError(error)) {
- forcedUnavailableReason.value = getSharedInstanceUnavailableReason(error)
- } else {
- notifyError(error)
- }
- })
+ watch(
+ updatePreviewQuery.error,
+ (error, previousError) => {
+ if (!error) return
+ if (isSharedInstanceUnavailableError(error)) {
+ forcedUnavailableReason.value = getSharedInstanceUnavailableReason(error)
+ } else if (
+ !previousError &&
+ updatePreviewQuery.data.value === undefined &&
+ (!instance.value?.id || !instanceLaunch.isCheckingPreview(instance.value.id))
+ ) {
+ notifyError(error)
+ }
+ },
+ { flush: 'sync' },
+ )
const unavailableReason = computed(() => forcedUnavailableReason.value)
const updatePreview = computed(() =>
@@ -148,7 +152,9 @@ export function createSharedInstanceContext(
async function refreshUpdatePreview() {
forcedUnavailableReason.value = null
if (!instance.value?.id || !auth.user.value?.id) return null
- const result = await updatePreviewQuery.refetch({ throwOnError: true })
+ const result = await instanceLaunch.runPreviewCheck(instance.value.id, () =>
+ updatePreviewQuery.refetch({ throwOnError: true }),
+ )
return result.data ?? null
}
diff --git a/apps/app-frontend/src/pages/instance/worlds/index.vue b/apps/app-frontend/src/pages/instance/worlds/index.vue
index 1328d45d5de..47ab11360ca 100644
--- a/apps/app-frontend/src/pages/instance/worlds/index.vue
+++ b/apps/app-frontend/src/pages/instance/worlds/index.vue
@@ -82,11 +82,9 @@
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
:starting-instance="startingInstance"
- :refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
- :server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
- :rendered-motd="
- world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
- "
+ :refreshing="getServerData(world)?.refreshing"
+ :server-status="getServerData(world)?.status"
+ :rendered-motd="getServerData(world)?.renderedMotd"
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
:shortcut-instance-id="instance.id"
@play="() => joinWorld(world)"
@@ -141,6 +139,7 @@ import {
useReadyState,
useVIntl,
} from '@modrinth/ui'
+import { autoToHTML } from '@sfirew/minecraft-motd-parser'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { platform } from '@tauri-apps/plugin-os'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
@@ -287,6 +286,37 @@ const worldsReadyPending = useReadyState(worldsQuery)
const worlds = ref([])
const serverData = ref>({})
+const headerServerData = computed(() => {
+ const status = instancePage.serverStatus.value
+ return {
+ refreshing: instancePage.serverStatusLoading.value,
+ status,
+ renderedMotd: status?.description ? autoToHTML(status.description) : undefined,
+ }
+})
+
+function isHeaderServerAddress(address: string) {
+ const headerAddress = instancePage.serverAddress.value
+ const normalizedHeaderAddress = headerAddress && normalizeServerAddress(headerAddress)
+ return (
+ instancePage.isServerInstance.value &&
+ !!normalizedHeaderAddress &&
+ normalizeServerAddress(address) === normalizedHeaderAddress
+ )
+}
+
+function isHeaderServerWorld(world: World): world is ServerWorld {
+ return world.type === 'server' && isHeaderServerAddress(world.address)
+}
+
+function getServerData(world: World): ServerData | undefined {
+ if (world.type !== 'server') return undefined
+ return isHeaderServerWorld(world) ? headerServerData.value : serverData.value[world.address]
+}
+
+function manualPingWorlds() {
+ return worlds.value.filter((world) => !isHeaderServerWorld(world))
+}
// Track servers_updated calls on Linux to prevent server ping spam
const MAX_LINUX_REFRESHES = 3
@@ -312,7 +342,7 @@ watch(
// Manual refresh handles its own server pings to avoid double-pinging
if (!refreshingAll.value) {
void refreshServers(
- worlds.value,
+ manualPingWorlds(),
serverData.value,
protocolVersion.value,
protocolVersionReady.value,
@@ -414,13 +444,17 @@ async function initWorldsTab() {
protocolVersionReady.value = true
if (worlds.value.length > 0) {
- refreshServers(worlds.value, serverData.value, protocolVersion.value)
+ refreshServers(manualPingWorlds(), serverData.value, protocolVersion.value)
}
}
void initWorldsTab()
async function refreshServer(address: string) {
+ if (isHeaderServerAddress(address)) {
+ await instancePage.refreshServerStatus()
+ return
+ }
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
@@ -439,7 +473,7 @@ async function refreshAllWorlds() {
refreshingAll.value = true
try {
// Show loading on server rows immediately while the list refreshes
- for (const world of worlds.value) {
+ for (const world of manualPingWorlds()) {
if (world.type === 'server') {
if (!serverData.value[world.address]) {
serverData.value[world.address] = { refreshing: true }
@@ -450,12 +484,15 @@ async function refreshAllWorlds() {
}
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.value.id) })
- await refreshServers(
- worlds.value,
- serverData.value,
- protocolVersion.value,
- protocolVersionReady.value,
- )
+ await Promise.all([
+ refreshServers(
+ manualPingWorlds(),
+ serverData.value,
+ protocolVersion.value,
+ protocolVersionReady.value,
+ ),
+ instancePage.refreshServerStatus(),
+ ])
} finally {
refreshingAll.value = false
}
@@ -638,8 +675,8 @@ const filterOptions = computed(() => {
options.push({ id: 'vanilla', label: formatMessage(messages.vanillaFilter) })
options.push({ id: 'modded', label: formatMessage(messages.moddedFilter) })
}
- const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
- const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
+ const hasOnline = servers.some((x) => !!getServerData(x)?.status)
+ const hasOffline = servers.some((x) => !getServerData(x)?.status)
if (hasOnline && hasOffline) {
options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
@@ -687,7 +724,7 @@ const filteredWorlds = computed(() =>
let passesStatus = true
if (statusFilters.length > 0) {
- const isOnline = !!serverData.value[x.address]?.status
+ const isOnline = !!getServerData(x)?.status
passesStatus =
(statusFilters.includes('online') && isOnline) ||
(statusFilters.includes('offline') && !isOnline)
diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue
index 0b53e5c3d68..7f8d0b6a402 100644
--- a/apps/app-frontend/src/pages/project/Index.vue
+++ b/apps/app-frontend/src/pages/project/Index.vue
@@ -48,9 +48,9 @@
-
+
@@ -191,7 +191,7 @@
]"
/>
{{ formatMessage(messages.loadError) }}
@@ -214,7 +219,10 @@
{}"
+ @hide="serverInstallContent.onServerFlowHide"
@browse-modpacks="() => {}"
@create="serverInstallContent.handleServerModpackFlowCreate"
/>
@@ -283,12 +290,9 @@ import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
-import {
- fetchCachedServerStatus,
- getFreshCachedServerStatus,
-} from '@/composables/instances/use-server-status-query'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
+import { fetchCachedServerStatus, getFreshCachedServerStatus } from '@/features/servers/queries'
import {
get_organization,
get_project,
@@ -308,7 +312,7 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_instance_id } from '@/helpers/process'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerAddress } from '@/helpers/worlds'
-import { createServerInstallContent } from '@/platform/adapters/browse/server-install-content'
+import { useServerInstallContent } from '@/platform/adapters/browse/server-install-content'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { provideBreadcrumbParent, useBreadcrumb } from '@/shared/breadcrumbs'
@@ -319,6 +323,7 @@ const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const route = useRoute()
const router = useRouter()
+
const displayedProjectRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
@@ -432,7 +437,10 @@ const serverStatusOnline = ref(false)
const serverInstancePath = ref(null)
const serverPlaying = ref(false)
const serverSetupModalRef = ref(null)
-const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
+const serverInstallContent = useServerInstallContent({
+ serverSetupModalRef,
+ inheritBreadcrumbServer: true,
+})
serverInstallContent.watchServerContextChanges()
await serverInstallContent.initServerContext()
@@ -471,7 +479,7 @@ function buildProjectHref(path, extraQuery = {}) {
function buildBrowseHref(path) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
- if (key === 'b') continue
+ if (key === 'b' || key === 'g' || key === 'l') continue
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
@@ -491,6 +499,7 @@ const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.par
const projectBrowseBackUrl = computed(() => {
const browsePath = route.query.b
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
+ if (serverInstallContent.isServerContext.value) return serverInstallContent.serverBackUrl.value
const instanceId = route.query.i
if (typeof instanceId === 'string' && instanceId) {
return `/instance/${encodeURIComponent(instanceId)}`
@@ -498,19 +507,21 @@ const projectBrowseBackUrl = computed(() => {
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
return buildBrowseHref(`/browse/${type}`)
})
-const projectBackLabel = computed(() =>
- typeof route.query.i === 'string' && typeof route.query.b !== 'string'
+const projectBackLabel = computed(() => {
+ if (typeof route.query.b === 'string') return formatMessage(messages.backToBrowse)
+ if (serverInstallContent.isServerContext.value) return serverInstallContent.serverBackLabel.value
+ return typeof route.query.i === 'string'
? formatMessage(messages.backToInstance)
- : formatMessage(messages.backToBrowse),
-)
+ : formatMessage(messages.backToBrowse)
+})
const projectInstallContext = computed(() => {
const serverData = serverInstallContent.serverContextServerData.value
if (serverData) {
return {
name: serverData.name,
- loader: serverData.loader ?? '',
- gameVersion: serverData.mc_version ?? '',
+ loader: serverInstallContent.serverContextPlatform.value.loader ?? '',
+ gameVersion: serverInstallContent.serverContextPlatform.value.gameVersion ?? '',
serverId: serverInstallContent.serverIdQuery.value,
upstream: serverData.upstream,
iconSrc: null,
@@ -544,22 +555,50 @@ const projectInstallContext = computed(() => {
return null
})
+/**
+ * Going back from a project returns to browse, where the selection is still shown, so the header
+ * skips the "selected projects not installed yet" prompt that would otherwise leave to the server.
+ */
+const projectHeaderInstallContext = computed(() => {
+ const context = projectInstallContext.value
+ if (!context) return null
+ return {
+ ...context,
+ selectedProjects: [],
+ isInstallingSelected: false,
+ }
+})
+
const serverProjectInstallContext = computed(
() =>
!!serverInstallContent.serverContextServerData.value &&
- ['modpack', 'mod', 'plugin', 'datapack'].includes(data.value?.project_type),
+ ['modpack', 'mod', 'plugin', 'datapack', 'resourcepack', 'shader'].includes(
+ data.value?.project_type,
+ ),
)
const serverProjectSelected = computed(
- () => !!data.value && serverInstallContent.queuedServerInstallProjectIds.value.has(data.value.id),
+ () =>
+ !serverInstallContent.isSetupServerContext.value &&
+ !!data.value &&
+ serverInstallContent.queuedServerInstallProjectIds.value.has(data.value.id),
)
const serverProjectInstalled = computed(
() =>
+ !serverInstallContent.isSetupServerContext.value &&
!!data.value &&
(serverInstallContent.serverContentProjectIds.value.has(data.value.id) ||
serverInstallContent.serverContextServerData.value?.upstream?.project_id === data.value.id),
)
+const serverSelectedVersionId = computed(() =>
+ serverProjectInstallContext.value && serverProjectSelected.value
+ ? (serverInstallContent.getQueuedServerInstallPlans().get(data.value.id)?.versionId ?? null)
+ : null,
+)
const installButtonLoading = computed(
- () => installing.value || serverInstallContent.isInstallingQueuedServerInstalls.value,
+ () =>
+ installing.value ||
+ serverInstallContent.isInstallingQueuedServerInstalls.value ||
+ serverInstallContent.activeServerModpackInstallProjectId.value === data.value?.id,
)
const installButtonValidating = computed(
() =>
@@ -866,18 +905,25 @@ async function install(version) {
icon_url: data.value.icon_url,
},
contentType,
- mode: contentType === 'modpack' ? 'immediate' : 'queue',
+ mode:
+ contentType === 'modpack' || serverInstallContent.isSetupServerContext.value
+ ? 'immediate'
+ : 'queue',
selectedFilters: [],
providedFilters: [],
overriddenProvidedFilterTypes: [],
- targetPreferences: getTargetInstallPreferences(
- {
- gameVersion: serverInstallContent.serverContextServerData.value?.mc_version,
- loader: serverInstallContent.serverContextServerData.value?.loader,
- },
- contentType,
- ),
- getProjectVersions: async () => versions.value,
+ targetPreferences: serverInstallContent.isSetupServerContext.value
+ ? {}
+ : getTargetInstallPreferences(
+ {
+ gameVersion: serverInstallContent.serverContextPlatform.value.gameVersion,
+ loader: serverInstallContent.serverContextPlatform.value.loader,
+ },
+ contentType,
+ ),
+ updateChannel: version ? undefined : await serverInstallContent.getServerUpdateChannel(),
+ getProjectVersions: async () =>
+ version ? versions.value.filter((candidate) => candidate.id === version) : versions.value,
queue: {
get: serverInstallContent.getQueuedServerInstallPlans,
set: serverInstallContent.setQueuedServerInstallPlans,
@@ -885,6 +931,7 @@ async function install(version) {
install: (plan) =>
serverInstallContent.openServerModpackInstallFlow({
projectId: plan.projectId,
+ contentType: plan.contentType,
versionId: plan.versionId,
name: plan.project.title ?? plan.project.name ?? data.value.title,
iconUrl: plan.project.icon_url ?? undefined,
diff --git a/apps/app-frontend/src/pages/project/Version.vue b/apps/app-frontend/src/pages/project/Version.vue
index bae5edd5abd..f8648698aac 100644
--- a/apps/app-frontend/src/pages/project/Version.vue
+++ b/apps/app-frontend/src/pages/project/Version.vue
@@ -13,6 +13,26 @@
>
version && install(version.id)"
+ >
+
+
+ {{
+ serverInstalled
+ ? formatMessage(commonMessages.installedLabel)
+ : serverVersionSelected
+ ? formatMessage(commonMessages.selectedLabel)
+ : formatMessage(commonMessages.installButton)
+ }}
+
+
install(version.id)"
+ >
+
+
+
+
diff --git a/apps/app-frontend/src/platform/adapters/browse/browse-page.vue b/apps/app-frontend/src/platform/adapters/browse/browse-page.vue
index bfd71ced7f9..6159ed4bf28 100644
--- a/apps/app-frontend/src/platform/adapters/browse/browse-page.vue
+++ b/apps/app-frontend/src/platform/adapters/browse/browse-page.vue
@@ -9,7 +9,13 @@ import {
ServerStackIcon,
SpinnerIcon,
} from '@modrinth/assets'
-import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
+import type {
+ BrowseInstallContentType,
+ CardAction,
+ ProjectType,
+ ServerContentEnvironment,
+ Tags,
+} from '@modrinth/ui'
import {
BrowsePageLayout,
BrowseSidebar,
@@ -18,10 +24,14 @@ import {
CreationFlowModal,
defineMessages,
formatProjectTypeSentence,
+ getHostingModEnvironmentOverride,
getLatestMatchingInstallVersion,
+ getProjectVersionFilterQuery,
getSelectedInstallPreferences,
getTargetInstallPreferences,
injectNotificationManager,
+ isServerContentEnvironmentProjectType,
+ parseServerContentEnvironment,
preferencesDiffer,
provideBrowseManager,
requestInstall,
@@ -55,14 +65,13 @@ import {
instanceKeys,
instanceLinkedProjectQueryOptions,
} from '@/pages/instance/query-options'
-import {
- createServerInstallContent,
- provideServerInstallContent,
-} from '@/platform/adapters/browse/server-install-content'
-import { useAppServerBrowse } from '@/platform/adapters/browse/use-app-server-browse'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { type BreadcrumbDefinition, injectBreadcrumbManager } from '@/shared/breadcrumbs'
+import { useCachedServerIcon } from '@/shared/composables/use-cached-server-icon'
+
+import { provideServerInstallContent, useServerInstallContent } from './server-install-content'
+import { useAppServerBrowse } from './use-app-server-browse'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -111,7 +120,7 @@ const breadcrumbLabel = computed(() => {
const appSettings = useAppSettings()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref | null>(null)
-const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
+const serverInstallContent = useServerInstallContent({ serverSetupModalRef })
provideServerInstallContent(serverInstallContent)
const {
serverIdQuery,
@@ -121,6 +130,7 @@ const {
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
+ serverContextPlatform,
serverContentProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
@@ -138,13 +148,15 @@ const {
installQueuedServerInstallsAndBack,
initServerContext,
watchServerContextChanges,
- searchServerModpacks,
getServerProjectVersions,
- enforceSetupModpackRoute,
+ getServerUpdateChannel,
+ enforceServerSetupRoute,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
resolveQueuedServerInstallPlan,
+ activeServerModpackInstallProjectId,
openServerModpackInstallFlow,
+ onServerFlowHide,
onServerFlowBack,
handleServerModpackFlowCreate,
markServerProjectInstalled,
@@ -197,7 +209,7 @@ const instanceBreadcrumbDefinition = {
const serversBreadcrumbDefinition = {
slot: 'root',
id: 'servers',
- label: () => formatMessage(commonMessages.serversLabel),
+ label: 'Hosting',
to: '/hosting/manage/',
visual: { type: 'icon', component: ServerStackIcon },
} satisfies BreadcrumbDefinition
@@ -207,11 +219,16 @@ watch(serverBackUrl, (value) => {
serverBreadcrumbTo.value = value
}
})
+const serverIcon = useCachedServerIcon(() => String(displayedBrowseRoute.value.query.sid ?? ''))
const serverBreadcrumbDefinition = {
slot: 'server',
id: () => `server:${String(displayedBrowseRoute.value.query.sid ?? '')}`,
label: () => serverContextServerData.value?.name ?? formatMessage(commonMessages.loadingLabel),
- visual: { type: 'icon', component: ServerStackIcon },
+ visual: () => ({
+ type: 'image' as const,
+ src: serverIcon.value,
+ alt: serverContextServerData.value?.name,
+ }),
to: serverBreadcrumbTo,
} satisfies BreadcrumbDefinition
const breadcrumbDefinition = {
@@ -274,7 +291,7 @@ if (isFromWorlds.value && route.params.projectType !== 'server') {
})
}
-enforceSetupModpackRoute(route.params.projectType as string | undefined)
+enforceServerSetupRoute(route.params.projectType as string | undefined)
const allInstalledIds = computed(
() => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]),
@@ -435,6 +452,23 @@ const instanceFilters = computed(() => {
return filters
})
+const showServerEnvironment = computed(() => isServerContext.value && !isSetupServerContext.value)
+const serverEnvironment = ref(
+ parseServerContentEnvironment(route.query.env),
+)
+watch(
+ () => route.query.env,
+ (value) => {
+ serverEnvironment.value = parseServerContentEnvironment(value)
+ },
+)
+const serverEnvironmentOverride = computed(() =>
+ showServerEnvironment.value && projectType.value === 'mod'
+ ? getHostingModEnvironmentOverride(serverEnvironment.value)
+ : isSetupServerContext.value
+ ? getHostingModEnvironmentOverride(null)
+ : undefined,
+)
const serverHideInstalled = ref(false)
const hideSelectedServerInstalls = ref(false)
if (route.query.shi) {
@@ -463,18 +497,16 @@ const serverContextFilters = computed(() => {
if (!serverContextServerData.value) return filters
const pt = projectType.value
- if (pt !== 'modpack') {
- const gameVersion = serverContextServerData.value.mc_version
+ if (pt !== 'modpack' && !isSetupServerContext.value) {
+ const gameVersion = serverContextPlatform.value.gameVersion
if (gameVersion) filters.push({ type: 'game_version', option: gameVersion })
- const platform = serverContextServerData.value.loader?.toLowerCase()
- if (platform && ['fabric', 'forge', 'quilt', 'neoforge'].includes(platform))
+ const platform = serverContextPlatform.value.loader?.toLowerCase()
+ if (pt === 'mod' && platform && ['fabric', 'forge', 'quilt', 'neoforge'].includes(platform))
filters.push({ type: 'mod_loader', option: platform })
- if (platform && ['paper', 'purpur'].includes(platform))
+ if (pt === 'plugin' && platform && ['paper', 'purpur'].includes(platform))
filters.push({ type: 'plugin_loader', option: platform })
- if (pt === 'mod') filters.push({ type: 'environment', option: 'server' })
-
if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) {
for (const id of queuedServerInstallProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
@@ -483,10 +515,7 @@ const serverContextFilters = computed(() => {
}
if (pt === 'modpack') {
- filters.push(
- { type: 'environment', option: 'client' },
- { type: 'environment', option: 'server' },
- )
+ filters.push({ type: 'environment', option: 'server' })
if (hideInstalledModpacks.value && hiddenInstanceProjectIds.value.size > 0) {
for (const id of hiddenInstanceProjectIds.value) {
@@ -495,7 +524,11 @@ const serverContextFilters = computed(() => {
}
}
- if (serverHideInstalled.value && hiddenServerContentProjectIds.value.size > 0) {
+ if (
+ !isSetupServerContext.value &&
+ serverHideInstalled.value &&
+ hiddenServerContentProjectIds.value.size > 0
+ ) {
for (const id of hiddenServerContentProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
@@ -561,6 +594,22 @@ const messages = defineMessages({
id: 'search.filter.locked.server-environment.title',
defaultMessage: 'Only client-side mods can be added to the server instance',
},
+ gameVersionProvidedByServer: {
+ id: 'search.filter.locked.server-game-version.title',
+ defaultMessage: 'Game version is provided by the server',
+ },
+ modLoaderProvidedByServer: {
+ id: 'search.filter.locked.server-loader.title',
+ defaultMessage: 'Loader is provided by the server',
+ },
+ providedByServer: {
+ id: 'search.filter.locked.server',
+ defaultMessage: 'Provided by the server',
+ },
+ syncWithServer: {
+ id: 'search.filter.locked.server.sync',
+ defaultMessage: 'Sync with server',
+ },
gameVersionProvidedByInstance: {
id: 'search.filter.locked.instance-game-version.title',
defaultMessage: 'Game version is provided by the instance',
@@ -594,6 +643,10 @@ const messages = defineMessages({
id: 'app.browse.project-type.modpacks',
defaultMessage: 'Modpacks',
},
+ pluginsProjectType: {
+ id: 'app.browse.project-type.plugins',
+ defaultMessage: 'Plugins',
+ },
modsProjectType: { id: 'app.browse.project-type.mods', defaultMessage: 'Mods' },
resourcePacksProjectType: {
id: 'app.browse.project-type.resource-packs',
@@ -636,8 +689,8 @@ watch(
return
}
if (isSetupServerContext.value) {
- enforceSetupModpackRoute(newType)
- if (newType !== 'modpack') return
+ enforceServerSetupRoute(newType)
+ if (!['modpack', 'mod', 'plugin', 'datapack'].includes(newType)) return
}
if (!newType || newType === projectType.value) return
@@ -647,6 +700,19 @@ watch(
},
)
+watch(
+ [serverEnvironment, projectType, showServerEnvironment],
+ ([environment, type, show]) => {
+ if (!show || !browseRouteActive.value) return
+ if (type !== 'modpack' && isServerContentEnvironmentProjectType(environment, type)) return
+ router.replace({
+ path: '/browse/mod',
+ query: { ...route.query, env: environment ?? undefined },
+ })
+ },
+ { immediate: true },
+)
+
watch(
() => route.query.i,
async (nextInstanceId, previousInstanceId) => {
@@ -696,6 +762,7 @@ const selectableProjectTypes = computed(() => {
if (route.query.from) params.from = route.query.from
if (route.query.sid) params.sid = route.query.sid
if (effectiveServerWorldId.value) params.wid = effectiveServerWorldId.value
+ if (showServerEnvironment.value && serverEnvironment.value) params.env = serverEnvironment.value
const queryString = new URLSearchParams(params as Record).toString()
const suffix = queryString ? `?${queryString}` : ''
@@ -703,6 +770,43 @@ const selectableProjectTypes = computed(() => {
if (isSetupServerContext.value) {
return [
{ label: formatMessage(messages.modpacksProjectType), href: `/browse/modpack${suffix}` },
+ { label: formatMessage(messages.modsProjectType), href: `/browse/mod${suffix}` },
+ { label: formatMessage(messages.pluginsProjectType), href: `/browse/plugin${suffix}` },
+ { label: formatMessage(messages.dataPacksProjectType), href: `/browse/datapack${suffix}` },
+ ]
+ }
+
+ if (isServerContext.value) {
+ const shownForEnvironment = (type: string) =>
+ isServerContentEnvironmentProjectType(serverEnvironment.value, type)
+ return [
+ { label: formatMessage(messages.modsProjectType), href: `/browse/mod${suffix}` },
+ {
+ label: formatMessage(messages.pluginsProjectType),
+ href: `/browse/plugin${suffix}`,
+ shown:
+ shownForEnvironment('plugin') &&
+ tags.value.loaders.some(
+ (loader) =>
+ loader.name === serverContextPlatform.value.loader?.toLowerCase() &&
+ loader.supported_project_types.includes('plugin'),
+ ),
+ },
+ {
+ label: formatMessage(messages.resourcePacksProjectType),
+ href: `/browse/resourcepack${suffix}`,
+ shown: shownForEnvironment('resourcepack'),
+ },
+ {
+ label: formatMessage(messages.dataPacksProjectType),
+ href: `/browse/datapack${suffix}`,
+ shown: shownForEnvironment('datapack'),
+ },
+ {
+ label: formatMessage(messages.shadersProjectType),
+ href: `/browse/shader${suffix}`,
+ shown: shownForEnvironment('shader'),
+ },
]
}
@@ -739,8 +843,8 @@ const installContext = computed(() => {
if (isServerContext.value && serverContextServerData.value) {
return {
name: serverContextServerData.value.name,
- loader: serverContextServerData.value.loader ?? '',
- gameVersion: serverContextServerData.value.mc_version ?? '',
+ loader: serverContextPlatform.value.loader ?? '',
+ gameVersion: serverContextPlatform.value.gameVersion ?? '',
serverId: serverIdQuery.value,
upstream: serverContextServerData.value.upstream,
iconSrc: null as string | null,
@@ -807,10 +911,11 @@ function getCurrentSelectedInstallPreferences(projectTypeValue: string) {
}
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
+ if (isSetupServerContext.value) return {}
return getTargetInstallPreferences(
{
- gameVersion: serverContextServerData.value?.mc_version,
- loader: serverContextServerData.value?.loader,
+ gameVersion: serverContextPlatform.value.gameVersion,
+ loader: serverContextPlatform.value.loader,
},
contentType,
)
@@ -852,6 +957,7 @@ async function chooseInstanceInstallVersion(
const selectedVersion = getLatestMatchingInstallVersion(
await getInstallProjectVersions(project.project_id),
selectedPreferences,
+ targetInstance.update_channel,
)
if (!selectedVersion) {
@@ -899,27 +1005,35 @@ function getCardActions(
allInstalledIds.value.has(projectResult.project_id || '') ||
serverContentProjectIds.value.has(projectResult.project_id || '') ||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
- const isInstalling = installingProjectIds.value.has(projectResult.project_id)
- const showAsInstalled = isInstalled && currentProjectType !== 'modpack'
+ const isInstalling =
+ installingProjectIds.value.has(projectResult.project_id) ||
+ activeServerModpackInstallProjectId.value === projectResult.project_id
+ const showAsInstalled =
+ isInstalled && currentProjectType !== 'modpack' && !isSetupServerContext.value
if (
isServerContext.value &&
- ['modpack', 'mod', 'plugin', 'datapack'].includes(currentProjectType)
+ ['modpack', 'mod', 'plugin', 'datapack', 'resourcepack', 'shader'].includes(currentProjectType)
) {
- const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id)
- const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(projectResult.project_id)
+ const isQueued =
+ !isSetupServerContext.value &&
+ queuedServerInstallProjectIds.value.has(projectResult.project_id)
+ const isQueuedRoot =
+ !isSetupServerContext.value &&
+ queuedServerInstallRootProjectIds.value.has(projectResult.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value
+ const showAsInstalling = isInstalling || (isInstallingSelection && isQueuedRoot)
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = showAsInstalled
? commonMessages.installedLabel
: isQueued
- ? isInstalling || isInstallingSelection
+ ? showAsInstalling
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
: commonMessages.selectedLabel
- : isInstalling || isInstallingSelection
+ : showAsInstalling
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
@@ -928,16 +1042,11 @@ function getCardActions(
{
key: 'install',
label: formatMessage(installLabel),
- icon:
- isInstalling || isInstallingSelection
- ? SpinnerIcon
- : isQueued || showAsInstalled
- ? CheckIcon
- : PlusIcon,
- iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
+ icon: showAsInstalling ? SpinnerIcon : isQueued || showAsInstalled ? CheckIcon : PlusIcon,
+ iconClass: showAsInstalling ? 'animate-spin' : undefined,
disabled:
showAsInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
- color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
+ color: isQueued && !showAsInstalling ? 'green' : 'brand',
type: 'outlined',
onClick: async () => {
if (isQueuedRoot) {
@@ -956,7 +1065,7 @@ function getCardActions(
const plan = await requestInstall({
project: projectResult,
contentType,
- mode: isModpack ? 'immediate' : 'queue',
+ mode: isModpack || isSetupServerContext.value ? 'immediate' : 'queue',
selectedFilters: isModpack
? []
: stripServerRuntimeInstallFilters(searchState.currentFilters.value),
@@ -967,17 +1076,20 @@ function getCardActions(
searchState.overriddenProvidedFilterTypes.value,
),
targetPreferences: getServerInstallTargetPreferences(contentType),
+ updateChannel: await getServerUpdateChannel(),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
install: (plan) =>
openServerModpackInstallFlow({
projectId: plan.projectId,
+ contentType: plan.contentType,
versionId: plan.versionId,
name: plan.project.name,
iconUrl: plan.project.icon_url ?? undefined,
}),
})
- if (!isModpack) await resolveQueuedServerInstallPlan(plan)
+ if (!isModpack && !isSetupServerContext.value)
+ await resolveQueuedServerInstallPlan(plan)
} catch (err) {
if (!isModpack) removeQueuedServerInstall(projectResult.project_id)
handleError(err as Error)
@@ -1141,11 +1253,23 @@ async function search(requestParams: string) {
}
const lockedFilterMessages = computed(() => ({
- gameVersion: formatMessage(messages.gameVersionProvidedByInstance),
- modLoader: formatMessage(messages.modLoaderProvidedByInstance),
+ gameVersion: formatMessage(
+ isServerContext.value
+ ? messages.gameVersionProvidedByServer
+ : messages.gameVersionProvidedByInstance,
+ ),
+ modLoader: formatMessage(
+ isServerContext.value
+ ? messages.modLoaderProvidedByServer
+ : messages.modLoaderProvidedByInstance,
+ ),
environment: formatMessage(messages.environmentProvidedByServer),
- syncButton: formatMessage(messages.syncFilterButton),
- providedBy: formatMessage(messages.providedByInstance),
+ syncButton: formatMessage(
+ isServerContext.value ? messages.syncWithServer : messages.syncFilterButton,
+ ),
+ providedBy: formatMessage(
+ isServerContext.value ? messages.providedByServer : messages.providedByInstance,
+ ),
}))
const searchState = useBrowseSearch({
@@ -1153,13 +1277,15 @@ const searchState = useBrowseSearch({
tags,
active: browseRouteActive,
providedFilters: combinedProvidedFilters,
+ environmentOverride: serverEnvironmentOverride,
search,
- persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
+ persistentQueryParams: ['i', 'ai', 'shi', 'env', 'sid', 'wid', 'from'],
getExtraQueryParams: () => ({
sid: serverIdQuery.value || undefined,
wid: effectiveServerWorldId.value || undefined,
ai: instanceHideInstalled.value ? 'true' : undefined,
shi: serverHideInstalled.value ? 'true' : undefined,
+ env: (showServerEnvironment.value && serverEnvironment.value) || undefined,
}),
})
@@ -1264,18 +1390,29 @@ provideBrowseManager({
dismissedPhotosensitivityFilterWarning,
getProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => ({
path: `/project/${result.project_id ?? result.slug}`,
- query: getProjectBrowseQuery(),
+ query: {
+ ...getProjectBrowseQuery(),
+ ...getProjectVersionFilterQuery({
+ contentType: projectType.value,
+ selectedFilters: searchState.currentFilters.value,
+ providedFilters: combinedProvidedFilters.value,
+ overriddenProvidedFilterTypes: searchState.overriddenProvidedFilterTypes.value,
+ }),
+ },
}),
getServerProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => ({
path: `/project/${result.slug ?? result.project_id}`,
query: getProjectBrowseQuery(),
}),
selectableProjectTypes,
- showProjectTypeTabs: computed(() => !isServerContext.value),
+ showProjectTypeTabs: computed(() => true),
variant: 'app',
getCardActions,
installContext,
providedFilters: combinedProvidedFilters,
+ serverEnvironment,
+ showServerEnvironment,
+ hiddenFilterTypes: computed(() => (showServerEnvironment.value ? ['environment'] : [])),
hideInstalled: computed({
get: () => {
if (projectType.value === 'modpack') return hideInstalledModpacks.value
@@ -1341,16 +1478,15 @@ provideBrowseManager({
{}"
+ @hide="onServerFlowHide"
@browse-modpacks="() => {}"
@create="handleServerModpackFlowCreate"
/>
diff --git a/apps/app-frontend/src/platform/adapters/browse/server-install-content.ts b/apps/app-frontend/src/platform/adapters/browse/server-install-content.ts
index 33d6714e4a9..0986ee6ef22 100644
--- a/apps/app-frontend/src/platform/adapters/browse/server-install-content.ts
+++ b/apps/app-frontend/src/platform/adapters/browse/server-install-content.ts
@@ -10,9 +10,12 @@ import {
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
- type ProjectSearchResult,
+ injectServerOnboardingFlow,
readStoredServerInstallQueue,
resolveServerAddonInstallPlans,
+ resolveServerPlatform,
+ type ServerPlatform,
+ type UpdateChannelPolicy,
useServerContextRuntime,
useServerPanelSync,
waitForServerContextRuntimeReady,
@@ -22,6 +25,9 @@ import { useQueryClient } from '@tanstack/vue-query'
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
+import { config as appConfig } from '@/config'
+import { useBreadcrumbServerId } from '@/shared/breadcrumbs'
+
type ServerFlowFrom = 'onboarding' | 'reset-server'
type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
@@ -30,6 +36,7 @@ type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
}
export interface ServerModpackSelectionRequest {
+ contentType?: string
projectId: string
versionId: string
name: string
@@ -52,6 +59,7 @@ export interface ServerInstallContentContext {
isSetupServerContext: ComputedRef
effectiveServerWorldId: ComputedRef
serverContextServerData: Ref
+ serverContextPlatform: ComputedRef
serverContentProjectIds: Ref>
queuedServerInstallRootProjectIds: ComputedRef>
queuedServerInstallProjectIds: ComputedRef>
@@ -69,9 +77,9 @@ export interface ServerInstallContentContext {
installQueuedServerInstallsAndBack: () => Promise
initServerContext: () => Promise
watchServerContextChanges: () => void
- searchServerModpacks: (query: string, limit?: number) => Promise
getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]>
- enforceSetupModpackRoute: (currentProjectType: string | undefined) => void
+ getServerUpdateChannel: () => Promise
+ enforceServerSetupRoute: (currentProjectType: string | undefined) => void
getQueuedServerInstallPlans: () => Map>
setQueuedServerInstallPlans: (
plans: Map>,
@@ -79,7 +87,9 @@ export interface ServerInstallContentContext {
resolveQueuedServerInstallPlan: (
plan: BrowseInstallPlan,
) => Promise
+ activeServerModpackInstallProjectId: Ref
openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise
+ onServerFlowHide: () => void
onServerFlowBack: () => void
handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise
markServerProjectInstalled: (id: string) => void
@@ -93,17 +103,26 @@ function readQueryString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
-export function createServerInstallContent(opts: {
+export function useServerInstallContent(opts: {
serverSetupModalRef: Ref
+ /**
+ * Falls back to the server in the breadcrumb trail when the route has no `sid`, so pages reached
+ * from a server panel install to that server.
+ */
+ inheritBreadcrumbServer?: boolean
}) {
const { serverSetupModalRef } = opts
+ const breadcrumbServerId = opts.inheritBreadcrumbServer ? useBreadcrumbServerId() : null
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
+ const onboardingFlow = injectServerOnboardingFlow()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
- const serverIdQuery = computed(() => readQueryString(route.query.sid))
+ const serverIdQuery = computed(
+ () => readQueryString(route.query.sid) ?? breadcrumbServerId?.value ?? null,
+ )
const worldIdQuery = computed(() => readQueryString(route.query.wid))
const browseFrom = computed(() => readQueryString(route.query.from))
const serverFlowFrom = computed(() =>
@@ -141,6 +160,10 @@ export function createServerInstallContent(opts: {
const serverContextServerData = ref(
initialServerId ? getCachedServer(initialServerId) : null,
)
+ const serverContextContent = ref(null)
+ const serverContextPlatform = computed(() =>
+ resolveServerPlatform(serverContextServerData.value, serverContextContent.value),
+ )
const serverContentProjectIds = ref>(new Set())
const queuedServerInstalls = ref