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) }}

-
@@ -218,7 +218,12 @@
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 index 96d67be7125..5b5daa731b7 100644 --- 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 @@ -19,7 +19,7 @@
{{ username }} {{ - row ? methodLabels[row.method] : '' + row ? formatMessage(methodMessages[row.method]) : '' }}
@@ -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 @@
- +
@@ -214,7 +219,10 @@ @@ -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 @@ > diff --git a/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue index 50d3a696ff2..eb28c8d4b3d 100644 --- a/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue +++ b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue @@ -1,7 +1,5 @@