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

-
@@ -222,7 +228,12 @@
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue index 96d67be7125..5b5daa731b7 100644 --- a/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue @@ -19,7 +19,7 @@
{{ username }} {{ - row ? methodLabels[row.method] : '' + row ? formatMessage(methodMessages[row.method]) : '' }}
@@ -56,7 +56,7 @@ import { } from '@modrinth/ui' import { computed, ref } from 'vue' -import { methodLabels, type ShareRow } from './shared-instance-share-types' +import { methodMessages, type ShareRow } from './shared-instance-share-types' const props = defineProps<{ row: ShareRow | null; memberCount: number }>() const emit = defineEmits<{ confirm: [row: ShareRow]; clear: [] }>() diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts b/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts index a9bebd01c25..c123661a264 100644 --- a/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts @@ -1,22 +1,8 @@ -export type ShareMethod = 'direct' | 'link' -export type MethodFilter = ShareMethod | 'all' -export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions' +export { + invitedPlayerMethodMessages as methodMessages, + normalizeInviteKey, + type InvitedPlayerMethod as ShareMethod, + type InvitedPlayerRow as ShareRow, +} from '@modrinth/ui' export const SHARED_INSTANCE_USER_LIMIT = 50 - -export type ShareRow = { - id: string - username: string - avatarUrl?: string - lastPlayedAt: Date | null - joinedAt: Date | null - method: ShareMethod - pending?: boolean -} - -export const methodLabels: Record = { - direct: 'Direct invite', - link: 'Share link', -} - -export { normalizeInviteKey } from '@modrinth/ui' diff --git a/apps/app-frontend/src/pages/instance/shared-instance-context.ts b/apps/app-frontend/src/pages/instance/shared-instance-context.ts index 163810122d2..2119321f955 100644 --- a/apps/app-frontend/src/pages/instance/shared-instance-context.ts +++ b/apps/app-frontend/src/pages/instance/shared-instance-context.ts @@ -1,18 +1,19 @@ +import { MinecraftServerIcon } from '@modrinth/assets' import { createContext, injectAuth } from '@modrinth/ui' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, type Ref, ref, watch } from 'vue' import { useUserQuery } from '@/composables/users/use-user-query' +import { injectInstanceLaunchState } from '@/features/instances/launch-state' import { getSharedInstanceUnavailableReason, - install_get_shared_instance_update_preview, isSharedInstanceUnavailableError, type SharedInstanceUnavailableReason, } from '@/helpers/install' -import { can_current_user_use_shared_instances } from '@/helpers/instance' +import { can_current_user_use_shared_instances, getInstanceIconUrl } from '@/helpers/instance' import type { GameInstance } from '@/helpers/types' -import { instanceKeys } from './query-options' +import { instanceKeys, sharedInstanceUpdatePreviewQueryOptions } from './query-options' export type SharedInstanceManager = | { @@ -35,6 +36,7 @@ export function createSharedInstanceContext( ) { const auth = injectAuth() const queryClient = useQueryClient() + const instanceLaunch = injectInstanceLaunchState() const forcedUnavailableReason = ref(null) const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null) @@ -62,7 +64,7 @@ export function createSharedInstanceContext( return { type: 'server', name: attachment.server_manager_name, - avatarUrl: attachment.server_manager_icon_url ?? undefined, + avatarUrl: getInstanceIconUrl(attachment.server_manager_icon_url) ?? MinecraftServerIcon, tintBy: attachment.server_manager_name, } } @@ -91,13 +93,10 @@ export function createSharedInstanceContext( () => !auth.session_token.value || eligibilityQuery.data.value !== false, ) - const updatePreviewQuery = useQuery({ - queryKey: computed(() => - instanceKeys.sharedUpdatePreview(instance.value?.id ?? '', auth.user.value?.id), - ), - queryFn: () => install_get_shared_instance_update_preview(instance.value!.id), - enabled: computed( - () => + const updatePreviewQuery = useQuery( + computed(() => ({ + ...sharedInstanceUpdatePreviewQueryOptions(instance.value?.id ?? '', auth.user.value?.id), + enabled: !!instance.value?.id && instance.value.install_stage === 'installed' && !!instance.value.shared_instance && @@ -106,23 +105,28 @@ export function createSharedInstanceContext( (auth.isReady?.value ?? true) && !!auth.session_token.value && !!auth.user.value?.id, - ), - retry: false, - staleTime: 30_000, - refetchOnWindowFocus: false, - }) + })), + ) watch(updatePreviewQuery.data, (preview) => { if (preview !== undefined) forcedUnavailableReason.value = null }) - watch(updatePreviewQuery.error, (error) => { - if (!error) return - if (isSharedInstanceUnavailableError(error)) { - forcedUnavailableReason.value = getSharedInstanceUnavailableReason(error) - } else { - notifyError(error) - } - }) + watch( + updatePreviewQuery.error, + (error, previousError) => { + if (!error) return + if (isSharedInstanceUnavailableError(error)) { + forcedUnavailableReason.value = getSharedInstanceUnavailableReason(error) + } else if ( + !previousError && + updatePreviewQuery.data.value === undefined && + (!instance.value?.id || !instanceLaunch.isCheckingPreview(instance.value.id)) + ) { + notifyError(error) + } + }, + { flush: 'sync' }, + ) const unavailableReason = computed(() => forcedUnavailableReason.value) const updatePreview = computed(() => @@ -148,7 +152,9 @@ export function createSharedInstanceContext( async function refreshUpdatePreview() { forcedUnavailableReason.value = null if (!instance.value?.id || !auth.user.value?.id) return null - const result = await updatePreviewQuery.refetch({ throwOnError: true }) + const result = await instanceLaunch.runPreviewCheck(instance.value.id, () => + updatePreviewQuery.refetch({ throwOnError: true }), + ) return result.data ?? null } diff --git a/apps/app-frontend/src/pages/instance/worlds/index.vue b/apps/app-frontend/src/pages/instance/worlds/index.vue index 1328d45d5de..47ab11360ca 100644 --- a/apps/app-frontend/src/pages/instance/worlds/index.vue +++ b/apps/app-frontend/src/pages/instance/worlds/index.vue @@ -82,11 +82,9 @@ :playing-instance="playing" :playing-world="worldsMatch(world, worldPlaying)" :starting-instance="startingInstance" - :refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined" - :server-status="world.type === 'server' ? serverData[world.address]?.status : undefined" - :rendered-motd=" - world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined - " + :refreshing="getServerData(world)?.refreshing" + :server-status="getServerData(world)?.status" + :rendered-motd="getServerData(world)?.renderedMotd" :game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined" :shortcut-instance-id="instance.id" @play="() => joinWorld(world)" @@ -141,6 +139,7 @@ import { useReadyState, useVIntl, } from '@modrinth/ui' +import { autoToHTML } from '@sfirew/minecraft-motd-parser' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { platform } from '@tauri-apps/plugin-os' import { computed, onBeforeUnmount, ref, watch } from 'vue' @@ -287,6 +286,37 @@ const worldsReadyPending = useReadyState(worldsQuery) const worlds = ref([]) const serverData = ref>({}) +const headerServerData = computed(() => { + const status = instancePage.serverStatus.value + return { + refreshing: instancePage.serverStatusLoading.value, + status, + renderedMotd: status?.description ? autoToHTML(status.description) : undefined, + } +}) + +function isHeaderServerAddress(address: string) { + const headerAddress = instancePage.serverAddress.value + const normalizedHeaderAddress = headerAddress && normalizeServerAddress(headerAddress) + return ( + instancePage.isServerInstance.value && + !!normalizedHeaderAddress && + normalizeServerAddress(address) === normalizedHeaderAddress + ) +} + +function isHeaderServerWorld(world: World): world is ServerWorld { + return world.type === 'server' && isHeaderServerAddress(world.address) +} + +function getServerData(world: World): ServerData | undefined { + if (world.type !== 'server') return undefined + return isHeaderServerWorld(world) ? headerServerData.value : serverData.value[world.address] +} + +function manualPingWorlds() { + return worlds.value.filter((world) => !isHeaderServerWorld(world)) +} // Track servers_updated calls on Linux to prevent server ping spam const MAX_LINUX_REFRESHES = 3 @@ -312,7 +342,7 @@ watch( // Manual refresh handles its own server pings to avoid double-pinging if (!refreshingAll.value) { void refreshServers( - worlds.value, + manualPingWorlds(), serverData.value, protocolVersion.value, protocolVersionReady.value, @@ -414,13 +444,17 @@ async function initWorldsTab() { protocolVersionReady.value = true if (worlds.value.length > 0) { - refreshServers(worlds.value, serverData.value, protocolVersion.value) + refreshServers(manualPingWorlds(), serverData.value, protocolVersion.value) } } void initWorldsTab() async function refreshServer(address: string) { + if (isHeaderServerAddress(address)) { + await instancePage.refreshServerStatus() + return + } if (!serverData.value[address]) { serverData.value[address] = { refreshing: true, @@ -439,7 +473,7 @@ async function refreshAllWorlds() { refreshingAll.value = true try { // Show loading on server rows immediately while the list refreshes - for (const world of worlds.value) { + for (const world of manualPingWorlds()) { if (world.type === 'server') { if (!serverData.value[world.address]) { serverData.value[world.address] = { refreshing: true } @@ -450,12 +484,15 @@ async function refreshAllWorlds() { } await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.value.id) }) - await refreshServers( - worlds.value, - serverData.value, - protocolVersion.value, - protocolVersionReady.value, - ) + await Promise.all([ + refreshServers( + manualPingWorlds(), + serverData.value, + protocolVersion.value, + protocolVersionReady.value, + ), + instancePage.refreshServerStatus(), + ]) } finally { refreshingAll.value = false } @@ -638,8 +675,8 @@ const filterOptions = computed(() => { options.push({ id: 'vanilla', label: formatMessage(messages.vanillaFilter) }) options.push({ id: 'modded', label: formatMessage(messages.moddedFilter) }) } - const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status) - const hasOffline = servers.some((x) => !serverData.value[x.address]?.status) + const hasOnline = servers.some((x) => !!getServerData(x)?.status) + const hasOffline = servers.some((x) => !getServerData(x)?.status) if (hasOnline && hasOffline) { options.push({ id: 'online', label: formatMessage(messages.onlineFilter) }) options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) }) @@ -687,7 +724,7 @@ const filteredWorlds = computed(() => let passesStatus = true if (statusFilters.length > 0) { - const isOnline = !!serverData.value[x.address]?.status + const isOnline = !!getServerData(x)?.status passesStatus = (statusFilters.includes('online') && isOnline) || (statusFilters.includes('offline') && !isOnline) diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 0b53e5c3d68..7f8d0b6a402 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -48,9 +48,9 @@
- +
@@ -214,7 +219,10 @@ @@ -283,12 +290,9 @@ import { useRoute, useRouter } from 'vue-router' import { SwapIcon } from '@/assets/icons/index.js' import InstanceIndicator from '@/components/ui/InstanceIndicator.vue' -import { - fetchCachedServerStatus, - getFreshCachedServerStatus, -} from '@/composables/instances/use-server-status-query' import { useAppEvent } from '@/composables/use-app-event' import { useAppSettings } from '@/composables/use-app-settings.ts' +import { fetchCachedServerStatus, getFreshCachedServerStatus } from '@/features/servers/queries' import { get_organization, get_project, @@ -308,7 +312,7 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata' import { get_by_instance_id } from '@/helpers/process' import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' import { getServerAddress } from '@/helpers/worlds' -import { createServerInstallContent } from '@/platform/adapters/browse/server-install-content' +import { useServerInstallContent } from '@/platform/adapters/browse/server-install-content' import { injectContentInstall } from '@/providers/content-install' import { injectServerInstall } from '@/providers/server-install' import { provideBreadcrumbParent, useBreadcrumb } from '@/shared/breadcrumbs' @@ -319,6 +323,7 @@ const { handleError } = injectNotificationManager() const { install: installVersion } = injectContentInstall() const route = useRoute() const router = useRouter() + const displayedProjectRoute = shallowRef(router.currentRoute.value) watch( () => router.currentRoute.value, @@ -432,7 +437,10 @@ const serverStatusOnline = ref(false) const serverInstancePath = ref(null) const serverPlaying = ref(false) const serverSetupModalRef = ref(null) -const serverInstallContent = createServerInstallContent({ serverSetupModalRef }) +const serverInstallContent = useServerInstallContent({ + serverSetupModalRef, + inheritBreadcrumbServer: true, +}) serverInstallContent.watchServerContextChanges() await serverInstallContent.initServerContext() @@ -471,7 +479,7 @@ function buildProjectHref(path, extraQuery = {}) { function buildBrowseHref(path) { const params = new URLSearchParams() for (const [key, val] of Object.entries(route.query)) { - if (key === 'b') continue + if (key === 'b' || key === 'g' || key === 'l') continue if (Array.isArray(val)) { for (const v of val) params.append(key, v) } else if (val) { @@ -491,6 +499,7 @@ const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.par const projectBrowseBackUrl = computed(() => { const browsePath = route.query.b if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath + if (serverInstallContent.isServerContext.value) return serverInstallContent.serverBackUrl.value const instanceId = route.query.i if (typeof instanceId === 'string' && instanceId) { return `/instance/${encodeURIComponent(instanceId)}` @@ -498,19 +507,21 @@ const projectBrowseBackUrl = computed(() => { const type = data.value?.project_type ? `${data.value.project_type}` : 'mod' return buildBrowseHref(`/browse/${type}`) }) -const projectBackLabel = computed(() => - typeof route.query.i === 'string' && typeof route.query.b !== 'string' +const projectBackLabel = computed(() => { + if (typeof route.query.b === 'string') return formatMessage(messages.backToBrowse) + if (serverInstallContent.isServerContext.value) return serverInstallContent.serverBackLabel.value + return typeof route.query.i === 'string' ? formatMessage(messages.backToInstance) - : formatMessage(messages.backToBrowse), -) + : formatMessage(messages.backToBrowse) +}) const projectInstallContext = computed(() => { const serverData = serverInstallContent.serverContextServerData.value if (serverData) { return { name: serverData.name, - loader: serverData.loader ?? '', - gameVersion: serverData.mc_version ?? '', + loader: serverInstallContent.serverContextPlatform.value.loader ?? '', + gameVersion: serverInstallContent.serverContextPlatform.value.gameVersion ?? '', serverId: serverInstallContent.serverIdQuery.value, upstream: serverData.upstream, iconSrc: null, @@ -544,22 +555,50 @@ const projectInstallContext = computed(() => { return null }) +/** + * Going back from a project returns to browse, where the selection is still shown, so the header + * skips the "selected projects not installed yet" prompt that would otherwise leave to the server. + */ +const projectHeaderInstallContext = computed(() => { + const context = projectInstallContext.value + if (!context) return null + return { + ...context, + selectedProjects: [], + isInstallingSelected: false, + } +}) + const serverProjectInstallContext = computed( () => !!serverInstallContent.serverContextServerData.value && - ['modpack', 'mod', 'plugin', 'datapack'].includes(data.value?.project_type), + ['modpack', 'mod', 'plugin', 'datapack', 'resourcepack', 'shader'].includes( + data.value?.project_type, + ), ) const serverProjectSelected = computed( - () => !!data.value && serverInstallContent.queuedServerInstallProjectIds.value.has(data.value.id), + () => + !serverInstallContent.isSetupServerContext.value && + !!data.value && + serverInstallContent.queuedServerInstallProjectIds.value.has(data.value.id), ) const serverProjectInstalled = computed( () => + !serverInstallContent.isSetupServerContext.value && !!data.value && (serverInstallContent.serverContentProjectIds.value.has(data.value.id) || serverInstallContent.serverContextServerData.value?.upstream?.project_id === data.value.id), ) +const serverSelectedVersionId = computed(() => + serverProjectInstallContext.value && serverProjectSelected.value + ? (serverInstallContent.getQueuedServerInstallPlans().get(data.value.id)?.versionId ?? null) + : null, +) const installButtonLoading = computed( - () => installing.value || serverInstallContent.isInstallingQueuedServerInstalls.value, + () => + installing.value || + serverInstallContent.isInstallingQueuedServerInstalls.value || + serverInstallContent.activeServerModpackInstallProjectId.value === data.value?.id, ) const installButtonValidating = computed( () => @@ -866,18 +905,25 @@ async function install(version) { icon_url: data.value.icon_url, }, contentType, - mode: contentType === 'modpack' ? 'immediate' : 'queue', + mode: + contentType === 'modpack' || serverInstallContent.isSetupServerContext.value + ? 'immediate' + : 'queue', selectedFilters: [], providedFilters: [], overriddenProvidedFilterTypes: [], - targetPreferences: getTargetInstallPreferences( - { - gameVersion: serverInstallContent.serverContextServerData.value?.mc_version, - loader: serverInstallContent.serverContextServerData.value?.loader, - }, - contentType, - ), - getProjectVersions: async () => versions.value, + targetPreferences: serverInstallContent.isSetupServerContext.value + ? {} + : getTargetInstallPreferences( + { + gameVersion: serverInstallContent.serverContextPlatform.value.gameVersion, + loader: serverInstallContent.serverContextPlatform.value.loader, + }, + contentType, + ), + updateChannel: version ? undefined : await serverInstallContent.getServerUpdateChannel(), + getProjectVersions: async () => + version ? versions.value.filter((candidate) => candidate.id === version) : versions.value, queue: { get: serverInstallContent.getQueuedServerInstallPlans, set: serverInstallContent.setQueuedServerInstallPlans, @@ -885,6 +931,7 @@ async function install(version) { install: (plan) => serverInstallContent.openServerModpackInstallFlow({ projectId: plan.projectId, + contentType: plan.contentType, versionId: plan.versionId, name: plan.project.title ?? plan.project.name ?? data.value.title, iconUrl: plan.project.icon_url ?? undefined, diff --git a/apps/app-frontend/src/pages/project/Version.vue b/apps/app-frontend/src/pages/project/Version.vue index bae5edd5abd..f8648698aac 100644 --- a/apps/app-frontend/src/pages/project/Version.vue +++ b/apps/app-frontend/src/pages/project/Version.vue @@ -13,6 +13,26 @@ > @@ -83,12 +112,18 @@ import { StopCircleIcon, UpdatedIcon, } from '@modrinth/assets' -import { computed } from 'vue' +import { computed, ref } from 'vue' import type { ButtonMenuOption } from '#ui/components/base/buttons' import { Button, SplitButton } from '#ui/components/base/buttons' +import { defineMessages, useVIntl } from '#ui/composables/i18n' +import { useServerPermissions } from '#ui/composables/server-permissions' +import ServerSharePushModal from '#ui/layouts/shared/server-sharing/components/ServerSharePushModal.vue' +import { useServerShareReview } from '#ui/layouts/shared/server-sharing/use-server-share-review' +import { useSharedInstanceInstalledOnce } from '#ui/layouts/wrapped/hosting/manage/[id]/play/use-server-players' +import { injectModrinthServerContext } from '#ui/providers' -import { useServerPowerAction } from './use-server-power-action' +import { type PowerAction, useServerPowerAction } from './use-server-power-action' const props = withDefaults( defineProps<{ @@ -99,20 +134,62 @@ const props = withDefaults( }, ) +const { formatMessage } = useVIntl() +const { serverId, worldId, serverFull } = injectModrinthServerContext() +const { canSetup } = useServerPermissions() +const world = computed(() => serverFull.value?.worlds.find((world) => world.id === worldId.value)) +const installedOnce = useSharedInstanceInstalledOnce( + computed(() => world.value?.content?.shared_instance_id ?? null), +) +const needsShareReview = computed( + () => + canSetup.value && + (world.value?.content?.shared_instance_needs_update ?? false) && + installedOnce.value, +) +const previewAction = ref<'Start' | 'Restart'>('Start') +const shareReview = useServerShareReview<'Start' | 'Restart'>({ + execute: async (action, target) => { + await shareReview.publish(target) + if (target.isCurrent()) await sendPowerAction(action) + }, +}) +const { + diffModal, + previewOpen, + previewQuery, + actionMutation, + pending: sharePending, + runAction, +} = shareReview +const sharePendingAction = computed(() => + actionMutation.isPending.value ? actionMutation.variables.value?.action : undefined, +) + const { + isStartingRequest, isInstalling, + isRunning, isStopping, showRestartButton, busyTooltip, canTakeAction, canKill, primaryActionText, + sendPowerAction, initiateAction, handlePrimaryAction, } = useServerPowerAction({ - disabled: computed(() => props.disabled), + disabled: computed(() => props.disabled || sharePending.value), }) +function onPrimaryAction() { + if (!canTakeAction.value) return + if (!needsShareReview.value) return handlePrimaryAction() + previewAction.value = isRunning.value ? 'Restart' : 'Start' + void shareReview.showPreview(previewAction.value) +} + const stopMenuOptions = computed(() => [ { id: 'kill_server', @@ -121,4 +198,25 @@ const stopMenuOptions = computed(() => [ action: () => initiateAction('Kill'), }, ]) + +const messages = defineMessages({ + startBody: { + id: 'servers.power.share-changes-start-body', + defaultMessage: + 'Starting the server will push these changes to all invited players. They will be asked to update their instance the next time they play.', + }, + restartBody: { + id: 'servers.power.share-changes-restart-body', + defaultMessage: + 'Restarting the server will push these changes to all invited players. They will be asked to update their instance the next time they play.', + }, + pushAndStart: { + id: 'servers.power.push-and-start', + defaultMessage: 'Push update and start', + }, + pushAndRestart: { + id: 'servers.power.push-and-restart', + defaultMessage: 'Push update and restart', + }, +}) diff --git a/packages/ui/src/components/servers/server-header/use-server-power-action.ts b/packages/ui/src/components/servers/server-header/use-server-power-action.ts index 4978193ddc4..4b3b9f628e9 100644 --- a/packages/ui/src/components/servers/server-header/use-server-power-action.ts +++ b/packages/ui/src/components/servers/server-header/use-server-power-action.ts @@ -1,4 +1,4 @@ -import { computed, type Ref } from 'vue' +import { computed, type Ref, ref } from 'vue' import { useVIntl } from '#ui/composables/i18n' import { useServerPermissions } from '#ui/composables/server-permissions' @@ -16,6 +16,8 @@ export function useServerPowerAction(options?: { disabled?: Ref }) { const { serverId, powerState, busyReasons } = injectModrinthServerContext() const { addNotification } = injectNotificationManager() const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions() + const pendingAction = ref(null) + const isStartingRequest = computed(() => pendingAction.value === 'Start') const isInstalling = computed(() => busyReasons.value.some((reason) => reason.reason.id === 'servers.busy.installing'), @@ -31,6 +33,7 @@ export function useServerPowerAction(options?: { disabled?: Ref }) { const isBlockedByPropsBusyOrPermission = computed( () => !canUsePowerActions.value || + pendingAction.value !== null || Boolean(options?.disabled?.value) || busyReasons.value.length > 0, ) @@ -64,6 +67,8 @@ export function useServerPowerAction(options?: { disabled?: Ref }) { }) async function sendPowerAction(action: PowerAction) { + if (pendingAction.value) return + pendingAction.value = action try { await client.archon.servers_v0.power(serverId, action) } catch (error) { @@ -73,6 +78,8 @@ export function useServerPowerAction(options?: { disabled?: Ref }) { title: `Failed to ${action.toLowerCase()} server`, text: 'An error occurred while performing this action.', }) + } finally { + pendingAction.value = null } } @@ -90,6 +97,7 @@ export function useServerPowerAction(options?: { disabled?: Ref }) { } return { + isStartingRequest, isInstalling, isRunning, isStopping, diff --git a/packages/ui/src/components/sharing/SharedFilesPublishModal.vue b/packages/ui/src/components/sharing/SharedFilesPublishModal.vue new file mode 100644 index 00000000000..c28518c9ef2 --- /dev/null +++ b/packages/ui/src/components/sharing/SharedFilesPublishModal.vue @@ -0,0 +1,348 @@ + + + diff --git a/packages/ui/src/components/sharing/SharedFilesStorageMeter.vue b/packages/ui/src/components/sharing/SharedFilesStorageMeter.vue new file mode 100644 index 00000000000..176371b4a57 --- /dev/null +++ b/packages/ui/src/components/sharing/SharedFilesStorageMeter.vue @@ -0,0 +1,96 @@ + + + diff --git a/packages/ui/src/components/sharing/index.ts b/packages/ui/src/components/sharing/index.ts index 9404102e49c..7c1e809c2d6 100644 --- a/packages/ui/src/components/sharing/index.ts +++ b/packages/ui/src/components/sharing/index.ts @@ -1,2 +1,8 @@ export { default as InvitePlayersModal } from './invite-players-modal/index.vue' +export { default as InvitePlayersContent } from './invite-players-modal/invite-players-content.vue' export * from './invite-players-modal/types' +export { default as ServerInviteLinkContent } from './server-invite-link/ServerInviteLinkContent.vue' +export { default as ServerInviteLinkModal } from './server-invite-link/ServerInviteLinkModal.vue' +export { default as ServerSharingUpdateModal } from './server-sharing-update/ServerSharingUpdateModal.vue' +export type { SharedFilesPublishFile } from './SharedFilesPublishModal.vue' +export { default as SharedFilesPublishModal } from './SharedFilesPublishModal.vue' diff --git a/packages/ui/src/components/sharing/invite-players-modal/index.vue b/packages/ui/src/components/sharing/invite-players-modal/index.vue index 0b8570cd116..9f26d599133 100644 --- a/packages/ui/src/components/sharing/invite-players-modal/index.vue +++ b/packages/ui/src/components/sharing/invite-players-modal/index.vue @@ -7,206 +7,30 @@ no-padding noblur > -
-
-
- - - - -
-
- -
-
-
- {{ friendsHeading }} -
-
- {{ emptyFriendsLabel }} -
-
- -
-
- + - - diff --git a/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue index 50d3a696ff2..eb28c8d4b3d 100644 --- a/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue +++ b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue @@ -1,7 +1,5 @@