diff --git a/.github/workflows/theseus-build.yml b/.github/workflows/theseus-build.yml index d4903a4cbbd..6e8aa5a298f 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: false + 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/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index e9b449e7253..283e3f05788 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, @@ -34,12 +36,18 @@ import { LoadingBar, NotificationPanel, PopupNotificationPanel, + provideFileDownload, + provideIconCache, provideLoadingState, provideModalBehavior, provideModrinthClient, provideNotificationManager, providePageContext, providePopupNotificationManager, + provideServerInviteHandoff, + provideServerOnboardingFlow, + provideServerPlay, + ServerOnboardingModal, TeleportOverflowMenu, TooltipDirective, useDebugLogger, @@ -64,6 +72,7 @@ import { RouterView, useRoute, useRouter } from 'vue-router' import { useRouteLoading } from '@/app/runtime/use-route-loading' import AppSidebar from '@/app/shell/sidebar/index.vue' import AppTitleBar from '@/app/shell/title-bar/index.vue' +import { trackExternalFileDownload } from '@/components/ui/download-manager/external-file-downloads' import ErrorModal from '@/components/ui/ErrorModal.vue' import HostingUpdateRequired from '@/components/ui/HostingUpdateRequired.vue' import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue' @@ -88,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' @@ -157,6 +172,8 @@ import { instanceListQueryOptions, 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, @@ -283,6 +300,12 @@ 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 { data: authenticatedModrinthUser } = useQuery({ queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]), queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(), @@ -804,8 +827,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)) @@ -829,42 +858,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() @@ -954,6 +960,14 @@ const contentInstallModpackAlreadyInstalledModal = ref() const addServerToInstanceModal = ref() const incompatibilityWarningModal = ref() const installToPlayModal = ref() +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 sharedInstanceInviteHandler = ref() const updateToPlayModal = ref() @@ -1539,6 +1553,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 +2021,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/modal/InstallToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue index 068e33338be..19c8fd955a3 100644 --- a/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue @@ -7,10 +7,6 @@ width="544px" >
-

- {{ formatMessage(messages.inviteWarning) }} -

-
@@ -401,11 +397,6 @@ const messages = defineMessages({ id: 'app.modal.install-to-play.header', defaultMessage: 'Install to play', }, - inviteWarning: { - id: 'app.modal.install-to-play.invite-warning', - defaultMessage: - 'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.', - }, sharedInstance: { id: 'app.modal.install-to-play.shared-instance', defaultMessage: 'Shared instance', 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 @@
@@ -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,9 +312,9 @@ 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 { useServerInstallContent } from '@/platform/adapters/browse/server-install-content' import { injectContentInstall } from '@/providers/content-install' import { injectServerInstall } from '@/providers/server-install' -import { createServerInstallContent } from '@/providers/setup/server-install-content' import { provideBreadcrumbParent, useBreadcrumb } from '@/shared/breadcrumbs' dayjs.extend(relativeTime) @@ -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,11 +507,13 @@ 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 @@ -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,24 @@ 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.serverContextServerData.value?.mc_version, + loader: serverInstallContent.serverContextServerData.value?.loader, + }, + contentType, + ), + getProjectVersions: async () => + version ? versions.value.filter((candidate) => candidate.id === version) : versions.value, queue: { get: serverInstallContent.getQueuedServerInstallPlans, set: serverInstallContent.setQueuedServerInstallPlans, @@ -885,6 +930,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 @@ >