From a8c0385736b521074bedc065ae7dc7df5cfeae10 Mon Sep 17 00:00:00 2001
From: "Calum H. (IMB11)"
Date: Wed, 28 Oct 2026 15:16:24 +0000
Subject: [PATCH 01/29] feat: server play
---
.github/workflows/theseus-build.yml | 17 +-
apps/app-frontend/src/App.vue | 94 +-
.../src/app/runtime/use-route-loading.ts | 12 +-
.../components/ui/HostingUpdateRequired.vue | 4 +-
.../download-manager/download-manager-bar.vue | 4 +-
.../download-manager/download-manager-job.vue | 18 +-
.../download-manager-panel.vue | 2 +-
.../ui/modal/InstallToPlayModal.vue | 9 -
.../SharedInstancePublishModal.vue | 275 +---
.../SharedInstanceUpdateModal.vue | 1 +
.../shared-instance-install-modal/index.vue | 36 +-
.../use-shared-instance-preview-content.ts | 55 +-
.../use-shared-instance-invite-handler.ts | 75 +-
.../components/ui/world/RecentWorldsList.vue | 44 +-
.../src/components/ui/world/queries.ts | 21 +-
.../src/composables/use-app-settings.ts | 3 +-
.../src/features/hosting/play-handler.vue | 414 ++++++
.../src/features/hosting/prefetch.ts | 37 +
.../src/features/instances/hosting/queries.ts | 42 +
.../instances/hosting/use-hosting-instance.ts | 139 ++
.../src/features/instances/launch-state.ts | 42 +
.../instances/settings-modal/index.vue | 2 +-
...nstance-installation-settings-controls.vue | 71 -
.../settings-modal/sharing-settings.vue | 245 ----
.../settings-modal/sharing-settings/index.vue | 30 +
.../sharing-settings/use-sharing-settings.ts | 98 ++
.../servers/queries.ts} | 23 +-
.../updates/new-update-modal/index.vue | 389 +-----
.../new-update-modal/show-notification.ts | 9 +-
.../new-update-modal/use-notification.ts | 175 ++-
.../updates/new-update-modal/use-sync.ts | 254 ----
.../generated/app-events/CommandPayload.ts | 2 +-
.../generated/app-events/postcard/index.d.ts | 2 +-
.../generated/app-events/postcard/index.js | 8 +
apps/app-frontend/src/helpers/install.ts | 26 +-
.../src/helpers/shared-instance-errors.ts | 15 +-
apps/app-frontend/src/helpers/types.d.ts | 31 +-
apps/app-frontend/src/helpers/worlds.ts | 2 +-
.../app-frontend/src/locales/cs-CZ/index.json | 48 -
.../app-frontend/src/locales/en-US/index.json | 230 ++--
.../app-frontend/src/locales/fi-FI/index.json | 15 -
.../app-frontend/src/locales/he-IL/index.json | 9 -
.../app-frontend/src/locales/id-ID/index.json | 9 -
.../app-frontend/src/locales/ms-MY/index.json | 9 -
.../app-frontend/src/locales/pt-PT/index.json | 24 -
.../app-frontend/src/locales/ro-RO/index.json | 9 -
.../app-frontend/src/locales/sr-CS/index.json | 6 -
apps/app-frontend/src/main.js | 8 +-
apps/app-frontend/src/pages/Skins.vue | 37 +-
apps/app-frontend/src/pages/User.vue | 143 +-
.../instance/components/page-header/index.vue | 5 +-
.../instance-page-header-server-metadata.vue | 51 +-
.../src/pages/instance/content/index.vue | 225 +++-
.../src/pages/instance/instance-context.ts | 5 +
.../src/pages/instance/layout.vue | 202 ++-
.../src/pages/instance/query-options.ts | 19 +-
.../share/shared-instance-members-table.vue | 315 +----
.../shared-instance-remove-member-modal.vue | 4 +-
.../share/shared-instance-share-types.ts | 26 +-
.../pages/instance/shared-instance-context.ts | 56 +-
.../src/pages/instance/worlds/index.vue | 71 +-
apps/app-frontend/src/pages/project/Index.vue | 109 +-
.../src/pages/project/Version.vue | 26 +
.../src/pages/project/Versions.vue | 58 +-
.../platform/adapters/browse/browse-page.vue | 250 +++-
.../adapters/browse/server-install-content.ts | 192 ++-
.../adapters/browse/use-app-server-browse.ts | 5 +-
.../src/platform/adapters/file-download.ts | 15 +-
.../adapters/hosting/manage/layout.vue | 63 +-
.../adapters/hosting/manage/overview-page.vue | 2 +-
.../adapters/hosting/manage/play-page.vue | 32 +
.../src/platform/adapters/icon-cache.ts | 26 +-
.../src/platform/app-lib/icons/commands.ts | 6 +
.../src/providers/content-install.ts | 18 +-
apps/app-frontend/src/routes.js | 7 +-
.../src/shared/breadcrumbs/index.ts | 2 +-
.../src/shared/breadcrumbs/use-breadcrumb.ts | 15 +-
.../composables/use-cached-server-icon.ts | 8 +
apps/app/build.rs | 3 +
apps/app/capabilities/plugins.json | 7 +-
apps/app/package.json | 2 +-
apps/app/src/api/install.rs | 28 +-
apps/app/src/api/instance.rs | 6 +
apps/app/tauri.conf.json | 4 +-
apps/frontend/src/app.vue | 21 +
apps/frontend/src/composables/featureFlags.ts | 1 +
.../use-discover-project-type-links.ts | 82 ++
.../composables/use-server-install-content.ts | 201 ++-
apps/frontend/src/locales/en-US/index.json | 2 +-
apps/frontend/src/pages/[type]/[project].vue | 4 +-
apps/frontend/src/pages/discover.vue | 49 +-
.../src/pages/discover/[type]/index.vue | 91 +-
.../src/pages/hosting/manage/[id].vue | 42 +-
.../src/pages/hosting/manage/[id]/content.vue | 2 +-
.../src/pages/hosting/manage/[id]/play.vue | 36 +
apps/frontend/src/pages/share/[inviteId].vue | 13 +-
apps/labrinth/src/routes/v3/content/mod.rs | 19 +-
.../api-client/src/core/abstract-websocket.ts | 2 +
.../src/modules/archon/content/v1.ts | 150 +++
.../api-client/src/modules/archon/icons/v1.ts | 41 +
.../src/modules/archon/servers/internal.ts | 19 +
.../src/modules/archon/servers/v1.ts | 17 +
.../api-client/src/modules/archon/types.ts | 222 +++-
packages/api-client/src/modules/index.ts | 4 +
.../src/modules/kyros/content/v1.ts | 27 +-
.../api-client/src/modules/kyros/files/v0.ts | 16 +
.../src/modules/kyros/flameanvil/v1.ts | 174 +++
.../api-client/src/modules/kyros/types.ts | 104 ++
.../api-client/src/modules/labrinth/types.ts | 4 +
.../modules/shared-instances/instances/v1.ts | 51 +-
.../modules/shared-instances/invites/v1.ts | 38 +-
.../src/modules/shared-instances/types.ts | 26 +-
.../src/platform/websocket-generic.ts | 56 +-
.../app-lib/.env.prod-with-staging-archon | 2 +-
...0a13ea88d9f536f57dd38030e83fcf287ee5.json} | 24 +-
...881fd9c44bccaedb9b06585ff8f76146c6e57.json | 12 -
...3d250db42b88bdc02129733a3741893b1a501.json | 8 +-
...615d948a7b1685690731f0f38886def82c6d.json} | 56 +-
...cb52ab8ede236b3630491b6f0ecf822429404.json | 12 +
...ecb5cce2a56e715f264d20d10f8d0bd57f9e.json} | 24 +-
...0000_shared-instance-backtrack-version.sql | 2 +
packages/app-lib/src/api/handler.rs | 15 +
packages/app-lib/src/api/instance.rs | 24 +-
.../app-lib/src/api/instance/export_mrpack.rs | 5 +
packages/app-lib/src/api/instance/icon.rs | 6 +
.../app-lib/src/api/instance/shared/client.rs | 127 +-
.../src/api/instance/shared/content.rs | 359 ++++-
.../app-lib/src/api/instance/shared/diff.rs | 110 +-
.../app-lib/src/api/instance/shared/icons.rs | 119 ++
.../src/api/instance/shared/install.rs | 263 +++-
.../src/api/instance/shared/invites.rs | 10 +
.../app-lib/src/api/instance/shared/mod.rs | 30 +-
.../src/api/instance/shared/publish.rs | 249 ++--
.../app-lib/src/api/instance/shared/types.rs | 12 +
.../instance/synced_options/orchestration.rs | 7 +-
.../src/api/instance/synced_servers/mod.rs | 1 +
.../api/instance/synced_servers/modpack.rs | 59 +-
.../api/instance/synced_servers/operations.rs | 78 +-
packages/app-lib/src/api/pack/import/mod.rs | 46 +-
packages/app-lib/src/api/pack/install_from.rs | 17 +-
.../app-lib/src/api/pack/install_mrpack.rs | 33 +-
packages/app-lib/src/error.rs | 21 +-
packages/app-lib/src/event/mod.rs | 4 +
packages/app-lib/src/install/control.rs | 28 +-
packages/app-lib/src/install/diagnostics.rs | 30 +
packages/app-lib/src/install/mod.rs | 2 +
packages/app-lib/src/install/model.rs | 68 +-
packages/app-lib/src/install/recovery.rs | 3 +
packages/app-lib/src/install/runner.rs | 1174 ++++++++++-------
.../app-lib/src/install/shared_instance.rs | 584 +++++++-
packages/app-lib/src/launcher/download.rs | 8 +-
.../instances/adapters/sqlite/content_rows.rs | 13 +-
.../adapters/sqlite/instance_rows.rs | 5 +
.../commands/apply_content_install.rs | 25 +-
.../instances/commands/remove_instance.rs | 7 +
.../instances/commands/shared_instance.rs | 1 +
.../instances/commands/sync_content_files.rs | 2 +-
.../instances/model/content_set_sync_state.rs | 1 +
.../app-lib/src/state/instances/model/link.rs | 4 +
.../app-lib/src/state/instances/watcher.rs | 55 +-
packages/app-lib/src/util/fetch.rs | 88 +-
.../illustrations/server-invite-friends.png | Bin 0 -> 151752 bytes
.../server-sharing/server-play-demo.webm | Bin 0 -> 5826397 bytes
.../server-sharing/server-play-demo.webp | Bin 0 -> 189434 bytes
packages/assets/index.ts | 2 +
.../src/install/mod.rs | 42 +-
.../src/install/model.rs | 32 +
.../modrinth-content-management/src/lib.rs | 2 +-
.../tests/install.rs | 202 ++-
.../ui/src/components/base/BaseTerminal.vue | 15 +-
.../src/components/base/BigOptionButton.vue | 7 +-
.../src/components/base/FloatingActionBar.vue | 2 +-
packages/ui/src/components/base/NavTabs.vue | 189 +--
.../components/CustomSetupStage.vue | 5 +-
.../components/FinalConfigStage.vue | 2 +-
.../components/InviteFriendsStage.vue | 16 +
.../components/ModpackStage.vue | 20 +-
.../components/SetupTypeStage.vue | 51 +-
.../components/UploadingStage.vue | 26 +
.../curseforge-stage/archive-input.vue | 48 +
.../components/curseforge-stage/index.vue | 377 ++++++
.../creation-flow-context.ts | 259 +++-
.../flows/creation-flow-modal/curseforge.ts | 272 ++++
.../flows/creation-flow-modal/index.vue | 15 +-
.../creation-flow-modal/server-content.ts | 175 +++
.../server-onboarding-invite.ts | 25 +
.../stages/curseforge-stage.ts | 44 +
.../stages/custom-setup-stage.ts | 1 +
.../stages/final-config-stage.ts | 6 +-
.../flows/creation-flow-modal/stages/index.ts | 6 +
.../stages/invite-friends-stage.ts | 41 +
.../stages/uploading-stage.ts | 21 +
.../components/nav/PopupNotificationPanel.vue | 19 +-
.../notifications/NotificationToast.vue | 25 +-
.../components/project/server/ServerPing.vue | 2 +-
.../project/server/ServerRegion.vue | 19 +-
.../components/search/SearchSidebarFilter.vue | 38 +-
.../servers/ConfigPushPromptModal.vue | 117 ++
.../servers/CurseForgeFilesReviewModal.vue | 267 ++++
.../components/servers/InstallingBanner.vue | 1 +
.../src/components/servers/ServerListing.vue | 85 +-
.../components/servers/ServerManageStats.vue | 19 +-
.../servers/ServerOnboardingModal.vue | 248 ++++
.../servers/ServerSettingsModal.vue | 21 +-
.../components/servers/ServerSetupModal.vue | 41 +-
.../components/servers/access/AccessTable.vue | 90 +-
.../servers/access/events/AddonEvent.vue | 30 +
.../access/events/BasicStringEvent.vue | 5 +
.../servers/access/events/parser.ts | 22 +
.../servers/admonitions/BackupAdmonition.vue | 89 +-
.../admonitions/CurseForgeFilesAdmonition.vue | 55 +
.../admonitions/ServerPanelAdmonitions.vue | 223 +++-
.../admonitions/ShareUpdateAdmonition.vue | 58 +
.../components/servers/admonitions/index.ts | 1 +
.../edit-server-icon/EditServerIcon.vue | 294 ++---
.../components/servers/icons/ServerIcon.vue | 10 +-
packages/ui/src/components/servers/index.ts | 2 +
.../servers/labels/ServerSubdomainLabel.vue | 7 +-
.../ServerPlayMigrationContent.vue | 154 +++
.../ServerPlayMigrationModal.vue | 46 +
.../servers/play-migration/index.ts | 3 +
.../servers/play-migration/messages.ts | 16 +
.../server-header/PanelServerActionButton.vue | 110 +-
.../server-header/use-server-power-action.ts | 10 +-
.../sharing/SharedFilesPublishModal.vue | 348 +++++
.../sharing/SharedFilesStorageMeter.vue | 96 ++
packages/ui/src/components/sharing/index.ts | 6 +
.../sharing/invite-players-modal/index.vue | 389 +-----
.../invite-players-content.vue | 366 +++++
.../invite-players-modal-user-row.vue | 4 +-
.../sharing/invite-players-modal/types.ts | 22 +
.../ServerInviteLinkContent.vue | 143 ++
.../ServerInviteLinkModal.vue | 109 ++
.../ServerSharingUpdateModal.vue | 435 ++++++
.../ui/src/composables/hosting-intercom.ts | 36 +-
packages/ui/src/composables/index.ts | 4 +-
.../src/composables/server-backups-queue.ts | 68 +-
.../server-content-toggle-state.ts | 165 +++
.../src/composables/server-context-runtime.ts | 65 +
.../ui/src/composables/server-download.ts | 28 +-
.../server-installation-tracker.ts | 17 +-
packages/ui/src/composables/server-list.ts | 23 +
.../composables/server-manage-core-runtime.ts | 5 +
.../ui/src/composables/server-onboarding.ts | 38 +
.../ui/src/composables/server-panel-sync.ts | 255 +++-
.../src/composables/server-play-migration.ts | 131 ++
.../ui/src/composables/server-preferences.ts | 14 +
.../ui/src/composables/sticky-observer.ts | 6 -
.../ui/src/composables/use-cached-icon.ts | 30 +-
.../ui/src/composables/use-server-icon.ts | 49 +
.../ui/src/composables/use-server-image.ts | 134 --
packages/ui/src/composables/virtual-scroll.ts | 16 +-
packages/ui/src/layouts/index.ts | 2 +
.../browse-tab/composables/install-logic.ts | 61 +-
.../src/layouts/shared/browse-tab/header.vue | 6 +-
.../src/layouts/shared/browse-tab/layout.vue | 1 +
.../browse-tab/providers/browse-manager.ts | 7 +-
.../src/layouts/shared/browse-tab/sidebar.vue | 128 +-
.../ui/src/layouts/shared/console/layout.vue | 19 +-
.../components/ContentCardItem.vue | 183 ++-
.../components/ContentCardItemIcon.vue | 91 ++
.../components/ContentCardTable.vue | 202 ++-
.../components/ContentEnabledFor.vue | 138 ++
.../components/ContentSortMenu.vue | 86 ++
.../components/managed-content-card/index.vue | 21 +-
.../managed-content-card-footer.vue | 8 +-
.../managed-content-modal/index.vue | 330 ++---
.../modals/ConfirmEnvironmentModal.vue | 161 +++
.../modals/ContentDependencyWarningModal.vue | 2 +
.../composables/content-filtering.ts | 23 +-
.../content-tab/composables/content-sort.ts | 71 +
.../shared/content-tab/composables/index.ts | 1 +
.../use-content-metadata-filters.ts | 144 +-
.../src/layouts/shared/content-tab/layout.vue | 432 ++----
.../content-tab/providers/content-manager.ts | 5 +-
.../src/layouts/shared/content-tab/types.ts | 25 +
.../content-tab/utils/update-channels.ts | 21 +
.../files-tab/components/FileNavbar.vue | 19 +-
.../modals/FileUploadZipUrlModal.vue | 139 +-
.../src/layouts/shared/files-tab/layout.vue | 4 +-
.../components/ContentDiffList.vue | 200 ++-
.../components/ContentDiffModal.vue | 28 +-
.../shared/installation-settings/layout.vue | 51 +-
.../shared/installation-settings/types.ts | 1 +
.../composables/use-invited-players-table.ts | 76 ++
.../layouts/shared/invited-players/index.ts | 2 +
.../layouts/shared/invited-players/layout.vue | 320 +++++
.../layouts/shared/invited-players/types.ts | 18 +
.../shared/server-settings/pages/general.vue | 227 +++-
.../shared/server-settings/pages/index.ts | 1 +
.../server-settings/pages/installation.vue | 65 +-
.../shared/server-settings/pages/sharing.vue | 43 +
.../shared/server-settings/pages/support.vue | 51 +-
.../providers/server-settings.ts | 1 +
.../layouts/shared/server-settings/tabs.ts | 24 +-
.../layouts/shared/server-sharing/cache.ts | 39 +
.../components/ServerSharePushModal.vue | 92 ++
.../shared/server-sharing/config-files.ts | 38 +
.../layouts/shared/server-sharing/index.ts | 4 +
.../shared/server-sharing/invite-link.ts | 32 +
.../shared/server-sharing/query-options.ts | 44 +
.../shared/server-sharing/share-diff.ts | 72 +
.../server-sharing/use-server-share-review.ts | 205 +++
.../server-sharing/use-server-sharing.ts | 117 ++
.../components/active-invites.vue | 140 ++
.../components/revoke-invite-modal.vue | 42 +-
.../components/unpublish-modal.vue | 67 +
.../layouts/shared/sharing-settings/index.ts | 3 +
.../shared/sharing-settings/layout.vue | 133 ++
.../providers/sharing-settings.ts | 20 +
.../layouts/shared/sharing-settings/types.ts | 6 +
.../manage/[id]/access/audit-log-utils.ts | 8 +
.../hosting/manage/[id]/access/messages.ts | 16 +
.../hosting/manage/[id]/onboarding.vue | 288 +---
.../manage/[id]/play/ServerPlayCard.vue | 125 ++
.../wrapped/hosting/manage/[id]/play/play.vue | 442 +++++++
.../wrapped/hosting/manage/[id]/play/types.ts | 4 +
.../manage/[id]/play/use-server-players.ts | 229 ++++
.../wrapped/hosting/manage/content.vue | 901 +++++++++----
.../layouts/wrapped/hosting/manage/files.vue | 53 +-
.../layouts/wrapped/hosting/manage/index.vue | 29 +-
.../wrapped/hosting/manage/overview.vue | 18 +-
.../layouts/wrapped/hosting/manage/root.vue | 263 ++--
packages/ui/src/layouts/wrapped/index.ts | 1 +
packages/ui/src/locales/ar-SA/index.json | 21 -
packages/ui/src/locales/da-DK/index.json | 3 -
packages/ui/src/locales/en-US/index.json | 849 ++++++++++--
packages/ui/src/locales/es-ES/index.json | 105 --
packages/ui/src/locales/fi-FI/index.json | 18 -
packages/ui/src/locales/fil-PH/index.json | 12 -
packages/ui/src/locales/fr-FR/index.json | 105 --
packages/ui/src/locales/ja-JP/index.json | 102 --
packages/ui/src/locales/ms-MY/index.json | 69 -
packages/ui/src/locales/nl-NL/index.json | 105 --
packages/ui/src/locales/pl-PL/index.json | 105 --
packages/ui/src/locales/pt-PT/index.json | 3 -
packages/ui/src/locales/ro-RO/index.json | 54 -
packages/ui/src/locales/sr-CS/index.json | 105 --
packages/ui/src/locales/th-TH/index.json | 3 -
packages/ui/src/locales/tr-TR/index.json | 105 --
packages/ui/src/locales/uk-UA/index.json | 105 --
packages/ui/src/locales/vi-VN/index.json | 105 --
packages/ui/src/providers/file-download.ts | 1 +
packages/ui/src/providers/icon-cache.ts | 4 +-
packages/ui/src/providers/index.ts | 4 +
packages/ui/src/providers/page-context.ts | 1 +
.../ui/src/providers/popup-notifications.ts | 1 +
.../ui/src/providers/server-invite-handoff.ts | 78 ++
.../ui/src/providers/server-onboarding.ts | 71 +
.../ui/src/providers/server-play-migration.ts | 29 +
packages/ui/src/providers/server-play.ts | 23 +
packages/ui/src/providers/tooltip.ts | 7 +-
.../ui/src/providers/web-notifications.ts | 1 +
.../modal/UpdateToPlayModal.stories.ts | 72 +
.../stories/servers/AuditLogTable.stories.ts | 1 +
.../servers/CreationFlowModal.stories.ts | 6 +-
packages/ui/src/utils/common-messages.ts | 17 +-
packages/ui/src/utils/loaders.ts | 22 +
packages/ui/src/utils/regions.ts | 14 +-
packages/ui/src/utils/search.ts | 47 +-
packages/ui/src/utils/truncate.ts | 8 +-
pnpm-lock.yaml | 98 +-
362 files changed, 19192 insertions(+), 7671 deletions(-)
create mode 100644 apps/app-frontend/src/features/hosting/play-handler.vue
create mode 100644 apps/app-frontend/src/features/hosting/prefetch.ts
create mode 100644 apps/app-frontend/src/features/instances/hosting/queries.ts
create mode 100644 apps/app-frontend/src/features/instances/hosting/use-hosting-instance.ts
create mode 100644 apps/app-frontend/src/features/instances/launch-state.ts
delete mode 100644 apps/app-frontend/src/features/instances/settings-modal/sharing-settings.vue
create mode 100644 apps/app-frontend/src/features/instances/settings-modal/sharing-settings/index.vue
create mode 100644 apps/app-frontend/src/features/instances/settings-modal/sharing-settings/use-sharing-settings.ts
rename apps/app-frontend/src/{composables/instances/use-server-status-query.ts => features/servers/queries.ts} (70%)
delete mode 100644 apps/app-frontend/src/features/updates/new-update-modal/use-sync.ts
create mode 100644 apps/app-frontend/src/platform/adapters/hosting/manage/play-page.vue
create mode 100644 apps/app-frontend/src/shared/composables/use-cached-server-icon.ts
create mode 100644 apps/frontend/src/composables/use-discover-project-type-links.ts
create mode 100644 apps/frontend/src/pages/hosting/manage/[id]/play.vue
create mode 100644 packages/api-client/src/modules/archon/icons/v1.ts
create mode 100644 packages/api-client/src/modules/kyros/flameanvil/v1.ts
rename packages/app-lib/.sqlx/{query-b6f125f86f9d244b3d6539772316ee79596c8d11af873f1d6c2c1b5c42b4f82b.json => query-0f485627f6034aab753518d587a90a13ea88d9f536f57dd38030e83fcf287ee5.json} (82%)
delete mode 100644 packages/app-lib/.sqlx/query-6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57.json
rename packages/app-lib/.sqlx/{query-19ed8089877bbaf18e47002d0103928ea7e93333aa39c1469b2bbea9a3bb06db.json => query-9fb4bf0fc6222c2afbba12fe8523615d948a7b1685690731f0f38886def82c6d.json} (80%)
create mode 100644 packages/app-lib/.sqlx/query-b6322a2cf8233d62f670aed9acecb52ab8ede236b3630491b6f0ecf822429404.json
rename packages/app-lib/.sqlx/{query-33782354fdcb513411b2fdbe4dec3d31abe98464383a4984ad1dede7275d2ba4.json => query-dcd47a2ca922a0d42f0096eb7d90ecb5cce2a56e715f264d20d10f8d0bd57f9e.json} (82%)
create mode 100644 packages/app-lib/migrations/20261102120000_shared-instance-backtrack-version.sql
create mode 100644 packages/app-lib/src/api/instance/shared/icons.rs
create mode 100644 packages/assets/illustrations/server-invite-friends.png
create mode 100644 packages/assets/illustrations/server-sharing/server-play-demo.webm
create mode 100644 packages/assets/illustrations/server-sharing/server-play-demo.webp
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/components/InviteFriendsStage.vue
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/components/UploadingStage.vue
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/components/curseforge-stage/archive-input.vue
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/components/curseforge-stage/index.vue
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/curseforge.ts
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/server-content.ts
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/server-onboarding-invite.ts
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/stages/curseforge-stage.ts
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/stages/invite-friends-stage.ts
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/stages/uploading-stage.ts
create mode 100644 packages/ui/src/components/servers/ConfigPushPromptModal.vue
create mode 100644 packages/ui/src/components/servers/CurseForgeFilesReviewModal.vue
create mode 100644 packages/ui/src/components/servers/ServerOnboardingModal.vue
create mode 100644 packages/ui/src/components/servers/admonitions/CurseForgeFilesAdmonition.vue
create mode 100644 packages/ui/src/components/servers/admonitions/ShareUpdateAdmonition.vue
create mode 100644 packages/ui/src/components/servers/play-migration/ServerPlayMigrationContent.vue
create mode 100644 packages/ui/src/components/servers/play-migration/ServerPlayMigrationModal.vue
create mode 100644 packages/ui/src/components/servers/play-migration/index.ts
create mode 100644 packages/ui/src/components/servers/play-migration/messages.ts
create mode 100644 packages/ui/src/components/sharing/SharedFilesPublishModal.vue
create mode 100644 packages/ui/src/components/sharing/SharedFilesStorageMeter.vue
create mode 100644 packages/ui/src/components/sharing/invite-players-modal/invite-players-content.vue
create mode 100644 packages/ui/src/components/sharing/server-invite-link/ServerInviteLinkContent.vue
create mode 100644 packages/ui/src/components/sharing/server-invite-link/ServerInviteLinkModal.vue
create mode 100644 packages/ui/src/components/sharing/server-sharing-update/ServerSharingUpdateModal.vue
create mode 100644 packages/ui/src/composables/server-content-toggle-state.ts
create mode 100644 packages/ui/src/composables/server-list.ts
create mode 100644 packages/ui/src/composables/server-onboarding.ts
create mode 100644 packages/ui/src/composables/server-play-migration.ts
create mode 100644 packages/ui/src/composables/server-preferences.ts
create mode 100644 packages/ui/src/composables/use-server-icon.ts
delete mode 100644 packages/ui/src/composables/use-server-image.ts
create mode 100644 packages/ui/src/layouts/shared/content-tab/components/ContentCardItemIcon.vue
create mode 100644 packages/ui/src/layouts/shared/content-tab/components/ContentEnabledFor.vue
create mode 100644 packages/ui/src/layouts/shared/content-tab/components/ContentSortMenu.vue
create mode 100644 packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmEnvironmentModal.vue
create mode 100644 packages/ui/src/layouts/shared/content-tab/composables/content-sort.ts
create mode 100644 packages/ui/src/layouts/shared/invited-players/composables/use-invited-players-table.ts
create mode 100644 packages/ui/src/layouts/shared/invited-players/index.ts
create mode 100644 packages/ui/src/layouts/shared/invited-players/layout.vue
create mode 100644 packages/ui/src/layouts/shared/invited-players/types.ts
create mode 100644 packages/ui/src/layouts/shared/server-settings/pages/sharing.vue
create mode 100644 packages/ui/src/layouts/shared/server-sharing/cache.ts
create mode 100644 packages/ui/src/layouts/shared/server-sharing/components/ServerSharePushModal.vue
create mode 100644 packages/ui/src/layouts/shared/server-sharing/config-files.ts
create mode 100644 packages/ui/src/layouts/shared/server-sharing/index.ts
create mode 100644 packages/ui/src/layouts/shared/server-sharing/invite-link.ts
create mode 100644 packages/ui/src/layouts/shared/server-sharing/query-options.ts
create mode 100644 packages/ui/src/layouts/shared/server-sharing/share-diff.ts
create mode 100644 packages/ui/src/layouts/shared/server-sharing/use-server-share-review.ts
create mode 100644 packages/ui/src/layouts/shared/server-sharing/use-server-sharing.ts
create mode 100644 packages/ui/src/layouts/shared/sharing-settings/components/active-invites.vue
rename apps/app-frontend/src/features/instances/settings-modal/confirm-revoke-shared-instance-invite-modal.vue => packages/ui/src/layouts/shared/sharing-settings/components/revoke-invite-modal.vue (72%)
create mode 100644 packages/ui/src/layouts/shared/sharing-settings/components/unpublish-modal.vue
create mode 100644 packages/ui/src/layouts/shared/sharing-settings/index.ts
create mode 100644 packages/ui/src/layouts/shared/sharing-settings/layout.vue
create mode 100644 packages/ui/src/layouts/shared/sharing-settings/providers/sharing-settings.ts
create mode 100644 packages/ui/src/layouts/shared/sharing-settings/types.ts
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/play.vue
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/types.ts
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/use-server-players.ts
create mode 100644 packages/ui/src/providers/server-invite-handoff.ts
create mode 100644 packages/ui/src/providers/server-onboarding.ts
create mode 100644 packages/ui/src/providers/server-play-migration.ts
create mode 100644 packages/ui/src/providers/server-play.ts
diff --git a/.github/workflows/theseus-build.yml b/.github/workflows/theseus-build.yml
index f2c7dd4336c..eb476f28676 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
@@ -175,9 +180,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 -- --profile release-with-thin-lto' }}
- 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 }}
@@ -190,6 +195,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 -- --profile release-with-thin-lto' }}
+ 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 -- --profile release-with-thin-lto' }}
if: contains(matrix.artifact-target-name, 'linux')
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 8870be16c9e..d68e64ccd98 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,
@@ -42,6 +44,10 @@ import {
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
+ provideServerInviteHandoff,
+ provideServerOnboardingFlow,
+ provideServerPlay,
+ ServerOnboardingModal,
TeleportOverflowMenu,
TooltipDirective,
useDebugLogger,
@@ -91,7 +97,13 @@ 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 InstanceActionModals from '@/features/instances/instance-actions/instance-action-modals.vue'
+import {
+ createInstanceLaunchState,
+ provideInstanceLaunchState,
+} from '@/features/instances/launch-state'
import SharedInstanceModals from '@/features/library/shared-instances/shared-instance-modals.vue'
import { provideMinecraftAccounts } from '@/features/minecraft-accounts/context'
import AppSettingsModal from '@/features/settings/app-settings-modal/index.vue'
@@ -290,8 +302,12 @@ useAppEvent('ads_consent_required', handleAdsConsentRequired, appEvents)
const tauriApiClient = createAppClient()
provideModrinthClient(tauriApiClient)
+provideServerOnboardingFlow(createServerOnboardingFlow())
+provideServerInviteHandoff(createServerInviteHandoff())
provideFileDownload(createFileDownload(tauriApiClient, trackExternalFileDownload))
-provideIconCache(createIconCache())
+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(),
@@ -813,8 +829,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))
@@ -838,42 +860,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()
@@ -963,6 +962,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()
@@ -1544,6 +1551,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') {
@@ -2010,6 +2019,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 b34a6db512e..6936657db51 100644
--- a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
@@ -78,6 +78,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 1717a5ef0b0..dfdebbbb85d 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'"
@@ -197,23 +197,29 @@
{{ 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 429a159b92e..322c12757d8 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 ?? [])]),
@@ -438,6 +455,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) {
@@ -466,18 +500,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 })
@@ -486,10 +518,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) {
@@ -498,7 +527,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 })
}
@@ -564,6 +597,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',
@@ -597,6 +646,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',
@@ -639,8 +692,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
@@ -650,6 +703,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) => {
@@ -699,6 +765,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}` : ''
@@ -706,6 +773,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'),
+ },
]
}
@@ -742,8 +846,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,
@@ -810,10 +914,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,
)
@@ -855,6 +960,7 @@ async function chooseInstanceInstallVersion(
const selectedVersion = getLatestMatchingInstallVersion(
await getInstallProjectVersions(project.project_id),
selectedPreferences,
+ targetInstance.update_channel,
)
if (!selectedVersion) {
@@ -902,27 +1008,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
@@ -931,16 +1045,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) {
@@ -959,7 +1068,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),
@@ -970,17 +1079,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)
@@ -1145,11 +1257,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({
@@ -1157,13 +1281,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,
}),
})
@@ -1268,18 +1394,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
@@ -1345,16 +1482,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