From e58af98f218d1a0e851c156d683ea44bc3c63d31 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Fri, 24 Jul 2026 14:06:38 +0100 Subject: [PATCH] feat: instance sharing thru shared-instances service (#6569) * feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com> --- apps/app-frontend/src/App.vue | 131 +- .../src/components/GridDisplay.vue | 15 +- .../src/components/RowDisplay.vue | 15 +- .../src/components/ui/Instance.vue | 22 +- .../src/components/ui/friends/FriendsList.vue | 91 +- .../ui/instance-page-header/index.vue | 188 ++- .../instance/instance-admonitions/index.vue | 116 ++ .../instance-admonitions-messages.ts | 69 + ...ance-admonitions-shared-instance-stale.vue | 66 + ...dmonitions-shared-instance-unavailable.vue | 51 + ...onitions-shared-instance-wrong-account.vue | 78 ++ .../ui/instance/instance-admonitions/types.ts | 12 + .../InstallationSettings.vue | 99 +- .../ui/instance_settings/SharingSettings.vue | 42 + .../ui/modal/AuthGrantFlowWaitModal.vue | 43 - .../ui/modal/InstallToPlayModal.vue | 8 +- .../ui/modal/InstanceSettingsModal.vue | 11 + .../ui/modal/ModrinthAccountRequiredModal.vue | 265 ++++ .../components/ui/modal/UpdateToPlayModal.vue | 27 +- .../SharedInstanceAlreadyInstalledModal.vue | 100 ++ ...edInstanceInstallationSettingsControls.vue | 195 +++ .../SharedInstancePublishModal.vue | 189 +++ .../SharedInstanceUpdateModal.vue | 131 ++ .../shared-instance-install-modal/index.vue | 533 ++++++++ .../shared-instance-install-summary.vue | 60 + .../use-shared-instance-preview-content.ts | 135 ++ .../shared-instance-invite-handler/index.vue | 46 + .../shared-instance-invite-parser.ts | 29 + .../shared-instance-invite-types.ts | 29 + .../use-shared-instance-invite-handler.ts | 252 ++++ .../src/components/ui/world/InstanceItem.vue | 11 +- .../components/ui/world/RecentWorldsList.vue | 3 + .../src/components/ui/world/WorldItem.vue | 41 +- .../browse/install-job-notifications.ts | 7 + .../instances/use-managed-content-policy.ts | 124 ++ .../src/composables/use-friends.ts | 148 +++ .../src/composables/users/use-user-query.ts | 17 + apps/app-frontend/src/config.ts | 4 + apps/app-frontend/src/helpers/errors.ts | 11 + apps/app-frontend/src/helpers/friends.ts | 107 ++ apps/app-frontend/src/helpers/install.ts | 144 +- apps/app-frontend/src/helpers/instance.ts | 93 +- apps/app-frontend/src/helpers/mr_auth.ts | 6 +- apps/app-frontend/src/helpers/reports.ts | 19 + .../src/helpers/shared-instance-errors.ts | 98 ++ apps/app-frontend/src/helpers/types.d.ts | 33 +- apps/app-frontend/src/helpers/users.ts | 11 + .../app-frontend/src/locales/de-CH/index.json | 3 - .../app-frontend/src/locales/de-DE/index.json | 3 - .../app-frontend/src/locales/en-US/index.json | 378 +++++- .../src/locales/es-419/index.json | 3 - .../app-frontend/src/locales/fr-FR/index.json | 3 - .../app-frontend/src/locales/hu-HU/index.json | 3 - .../app-frontend/src/locales/it-IT/index.json | 3 - .../app-frontend/src/locales/ko-KR/index.json | 3 - .../app-frontend/src/locales/nl-NL/index.json | 3 - .../app-frontend/src/locales/pl-PL/index.json | 3 - .../app-frontend/src/locales/pt-BR/index.json | 3 - .../app-frontend/src/locales/ru-RU/index.json | 3 - .../app-frontend/src/locales/sr-CS/index.json | 3 - .../app-frontend/src/locales/sv-SE/index.json | 3 - .../app-frontend/src/locales/tr-TR/index.json | 3 - .../app-frontend/src/locales/uk-UA/index.json | 3 - .../app-frontend/src/locales/vi-VN/index.json | 3 - .../app-frontend/src/locales/zh-CN/index.json | 3 - .../app-frontend/src/locales/zh-TW/index.json | 3 - .../app-frontend/src/pages/instance/Index.vue | 331 ++++- apps/app-frontend/src/pages/instance/Mods.vue | 165 ++- .../src/pages/instance/Worlds.vue | 3 + apps/app-frontend/src/pages/instance/index.js | 3 +- .../src/pages/instance/share/index.vue | 373 ++++++ .../share/shared-instance-members-table.vue | 304 +++++ .../shared-instance-remove-member-modal.vue | 122 ++ .../shared-instance-share-empty-state.vue | 15 + .../share/shared-instance-share-types.ts | 20 + .../use-shared-instance-invite-candidates.ts | 105 ++ .../share/use-shared-instance-invite-link.ts | 63 + .../share/use-shared-instance-members.ts | 241 ++++ .../instance/use-shared-instance-state.ts | 228 ++++ apps/app-frontend/src/pages/project/Index.vue | 2 +- .../src/providers/instance-backup.ts | 25 + apps/app-frontend/src/providers/setup/auth.ts | 14 +- apps/app-frontend/src/routes.js | 9 + apps/app-frontend/vite.config.ts | 2 +- apps/app/build.rs | 32 +- apps/app/capabilities/plugins.json | 2 + apps/app/src/api/install.rs | 70 + apps/app/src/api/instance.rs | 166 ++- apps/app/src/api/mod.rs | 15 +- apps/app/src/api/mr_auth.rs | 3 +- apps/app/src/api/reports.rs | 15 + apps/app/src/api/users.rs | 13 + apps/app/src/main.rs | 8 +- apps/frontend/.env.local | 1 + apps/frontend/.env.prod | 1 + apps/frontend/.env.staging | 1 + apps/frontend/.env.staging-archon | 1 + apps/frontend/nuxt.config.ts | 12 + .../ui/SharedInstanceInviteOpenInAppModal.vue | 274 ++++ .../ui/moderation/ModerationReportCard.vue | 453 ++++++- .../SharedInstanceReportContext.vue | 442 +++++++ .../src/components/ui/report/ReportInfo.vue | 14 +- .../src/components/ui/thread/ThreadView.vue | 1 + apps/frontend/src/error.vue | 1 + apps/frontend/src/helpers/api.ts | 8 +- apps/frontend/src/helpers/moderation.ts | 46 +- apps/frontend/src/locales/en-US/index.json | 51 + .../frontend/src/middleware/project.global.ts | 1 + .../src/pages/dashboard/notifications.vue | 9 +- .../src/pages/moderation/reports/[report].vue | 5 +- .../src/pages/moderation/reports/index.vue | 17 +- apps/frontend/src/pages/share/[inviteId].vue | 262 ++++ .../src/providers/setup/modrinth-client.ts | 1 + apps/frontend/src/server/utils/api-client.ts | 3 + apps/frontend/wrangler.jsonc | 2 + .../src/routes/v3/version_creation.rs | 4 +- .../api-client/src/core/abstract-client.ts | 6 + packages/api-client/src/modules/index.ts | 8 + .../src/modules/labrinth/images/v3.ts | 2 +- .../api-client/src/modules/labrinth/types.ts | 7 +- .../modules/shared-instances/instances/v1.ts | 66 + .../modules/shared-instances/invites/v1.ts | 21 + .../modules/shared-instances/moderation/v1.ts | 41 + .../src/modules/shared-instances/types.ts | 101 ++ .../src/modules/shared-instances/users/v1.ts | 21 + packages/api-client/src/modules/types.ts | 1 + .../src/platform/xhr-upload-client.ts | 2 + packages/api-client/src/types/client.ts | 6 + packages/api-client/src/types/request.ts | 1 + packages/app-lib/.env.local | 1 + packages/app-lib/.env.prod | 1 + .../app-lib/.env.prod-with-staging-archon | 1 + packages/app-lib/.env.staging | 1 + ...32383be454526177686a0685a988af747db0f.json | 296 +++++ ...209885ef8e87ad2a8666a9cf1c851a2948cbc.json | 12 + ...dac61544130300cdf2cfc8deb7fbf2efaf8c7.json | 248 ---- ...881fd9c44bccaedb9b06585ff8f76146c6e57.json | 12 + ...a2c8b9074904d1012079660de316a20e7dfa.json} | 28 +- ...156fd72100b0cf97b4ecb11045c6c4f550652.json | 12 + ...e7f0614e912494f850e77dfc502b1afe7c42a.json | 296 +++++ ...2a6590b89c4f72449fdd34a7f140e2a11bf04.json | 296 +++++ ...00cae0b4b758c0674ac81659ee9d86667f19c.json | 12 + ...36b774d1b506e6cf5f2ab38469e9e3e6c9b6d.json | 248 ---- ...8af544c08d3e274a930a68daf3e94e3612243.json | 12 + ...df95c67b5ceda04aa1b0eb782b581e23ec06b.json | 12 + ...5ee588e751fdc3f18c48bc4cfd94504c37306.json | 248 ---- ...563cebedb6b8add6c936c551a857783645ff8.json | 12 - ...2d429a2cc73bc2c752594fcad7da19967ff7b.json | 248 ---- .../20260630120000_shared-instance-role.sql | 2 + ...20260707103000_shared-instance-manager.sql | 2 + ...0707110000_shared-instance-linked-user.sql | 2 + ...9190000_shared-instance-server-manager.sql | 5 + .../20260723120000_quarantined-instances.sql | 4 + packages/app-lib/src/api/handler.rs | 22 +- packages/app-lib/src/api/instance.rs | 23 + .../src/api/instance/content_set_diff.rs | 152 +++ .../app-lib/src/api/instance/lifecycle.rs | 17 +- packages/app-lib/src/api/instance/projects.rs | 111 +- packages/app-lib/src/api/instance/run.rs | 28 + .../app-lib/src/api/instance/shared/client.rs | 1014 ++++++++++++++ .../app-lib/src/api/instance/shared/diff.rs | 432 ++++++ .../src/api/instance/shared/install.rs | 574 ++++++++ .../src/api/instance/shared/invites.rs | 184 +++ .../app-lib/src/api/instance/shared/mod.rs | 140 ++ .../src/api/instance/shared/publish.rs | 1163 +++++++++++++++++ .../app-lib/src/api/instance/shared/types.rs | 162 +++ packages/app-lib/src/api/mod.rs | 5 +- packages/app-lib/src/api/mr_auth.rs | 15 +- packages/app-lib/src/api/reports.rs | 46 + packages/app-lib/src/api/users.rs | 32 + packages/app-lib/src/error.rs | 21 + packages/app-lib/src/event/mod.rs | 3 + packages/app-lib/src/install/mod.rs | 11 +- packages/app-lib/src/install/model.rs | 69 +- packages/app-lib/src/install/recovery.rs | 292 ++++- packages/app-lib/src/install/runner.rs | 278 +++- .../app-lib/src/install/shared_instance.rs | 1016 ++++++++++++++ packages/app-lib/src/launcher/mod.rs | 31 +- packages/app-lib/src/state/friends.rs | 5 + packages/app-lib/src/state/instance_types.rs | 17 +- .../state/instances/adapters/filesystem.rs | 5 +- .../instances/adapters/sqlite/content_rows.rs | 218 ++- .../adapters/sqlite/instance_rows.rs | 620 +++++---- .../commands/apply_content_install.rs | 38 + .../commands/apply_content_update.rs | 64 +- .../state/instances/commands/edit_instance.rs | 21 + .../state/instances/commands/get_instance.rs | 71 +- .../state/instances/commands/list_content.rs | 23 +- .../src/state/instances/commands/mod.rs | 6 + .../instances/commands/shared_instance.rs | 184 +++ .../instances/commands/sync_content_files.rs | 19 + .../app-lib/src/state/instances/content.rs | 2 + packages/app-lib/src/state/instances/mod.rs | 4 + .../src/state/instances/model/content_set.rs | 9 + .../app-lib/src/state/instances/model/link.rs | 82 +- .../app-lib/src/state/instances/watcher.rs | 42 +- packages/app-lib/src/state/mr_auth.rs | 4 + packages/app-lib/src/util/fetch.rs | 17 +- .../branding/illustrations/invite-bg.webp | Bin 0 -> 83234 bytes packages/assets/generated-icons.ts | 2 + packages/assets/icons/file-cog.svg | 25 + packages/assets/index.ts | 2 + packages/moderation/src/types/reports.ts | 7 +- packages/path-util/src/lib.rs | 43 + .../ui/src/components/base/Admonition.vue | 63 +- .../ui/src/components/base/FileTreeSelect.vue | 27 +- .../base/page-header/metadata/index.vue | 2 +- .../metadata/page-header-metadata-item.vue | 8 +- packages/ui/src/components/index.ts | 1 + .../components/modal/InstallToPlayModal.vue | 111 -- packages/ui/src/components/modal/NewModal.vue | 18 +- packages/ui/src/components/modal/index.ts | 1 - .../components/nav/PopupNotificationPanel.vue | 27 +- .../notifications/NotificationToast.vue | 54 +- packages/ui/src/components/sharing/index.ts | 2 + .../sharing/invite-players-modal/index.vue | 393 ++++++ ...nvite-players-modal-invite-link-editor.vue | 167 +++ .../invite-players-modal-user-row.vue | 101 ++ .../sharing/invite-players-modal/types.ts | 36 + .../use-invite-players-search.ts | 226 ++++ .../src/layouts/shared/browse-tab/layout.vue | 2 +- .../components/ContentCardTable.vue | 18 +- .../components/ContentModpackCard.vue | 143 +- .../components/ContentSelectionBar.vue | 12 +- .../modals/ConfirmDeletionModal.vue | 40 +- .../components/modals/ConfirmDisableModal.vue | 103 ++ .../modals/ConfirmModpackUpdateModal.vue | 4 + .../components/modals/ConfirmUnlinkModal.vue | 21 +- .../components/modals/ModpackContentModal.vue | 116 +- .../src/layouts/shared/content-tab/index.ts | 2 + .../src/layouts/shared/content-tab/layout.vue | 191 ++- .../content-tab/providers/content-manager.ts | 9 +- .../src/layouts/shared/content-tab/types.ts | 18 + .../components/ContentDiffModal.vue | 239 +++- .../shared/installation-settings/layout.vue | 36 +- .../providers/installation-settings.ts | 8 + .../shared/installation-settings/types.ts | 15 +- packages/ui/src/locales/ar-SA/index.json | 4 - packages/ui/src/locales/cs-CZ/index.json | 10 - packages/ui/src/locales/de-CH/index.json | 10 - packages/ui/src/locales/de-DE/index.json | 10 - packages/ui/src/locales/en-US/index.json | 126 +- packages/ui/src/locales/es-419/index.json | 10 - packages/ui/src/locales/es-ES/index.json | 9 - packages/ui/src/locales/fr-FR/index.json | 9 - packages/ui/src/locales/he-IL/index.json | 3 - packages/ui/src/locales/hu-HU/index.json | 9 - packages/ui/src/locales/id-ID/index.json | 10 - packages/ui/src/locales/it-IT/index.json | 9 - packages/ui/src/locales/ja-JP/index.json | 9 - packages/ui/src/locales/ko-KR/index.json | 9 - packages/ui/src/locales/ms-MY/index.json | 10 - packages/ui/src/locales/nl-NL/index.json | 9 - packages/ui/src/locales/no-NO/index.json | 10 - packages/ui/src/locales/pl-PL/index.json | 9 - packages/ui/src/locales/pt-BR/index.json | 9 - packages/ui/src/locales/pt-PT/index.json | 10 - packages/ui/src/locales/ru-RU/index.json | 9 - packages/ui/src/locales/sr-CS/index.json | 10 - packages/ui/src/locales/sv-SE/index.json | 10 - packages/ui/src/locales/tr-TR/index.json | 9 - packages/ui/src/locales/uk-UA/index.json | 9 - packages/ui/src/locales/vi-VN/index.json | 10 - packages/ui/src/locales/zh-CN/index.json | 10 - packages/ui/src/locales/zh-TW/index.json | 10 - packages/ui/src/providers/auth.ts | 10 +- .../NotificationToasts.stories.ts | 2 +- .../sharing/InvitePlayersModal.stories.ts | 202 +++ turbo.jsonc | 1 + 269 files changed, 18202 insertions(+), 2579 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue create mode 100644 apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts create mode 100644 apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue create mode 100644 apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue create mode 100644 apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue create mode 100644 apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts create mode 100644 apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue delete mode 100644 apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue create mode 100644 apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue create mode 100644 apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue create mode 100644 apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue create mode 100644 apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue create mode 100644 apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue create mode 100644 apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue create mode 100644 apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue create mode 100644 apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts create mode 100644 apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue create mode 100644 apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts create mode 100644 apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts create mode 100644 apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts create mode 100644 apps/app-frontend/src/composables/instances/use-managed-content-policy.ts create mode 100644 apps/app-frontend/src/composables/use-friends.ts create mode 100644 apps/app-frontend/src/composables/users/use-user-query.ts create mode 100644 apps/app-frontend/src/helpers/errors.ts create mode 100644 apps/app-frontend/src/helpers/reports.ts create mode 100644 apps/app-frontend/src/helpers/shared-instance-errors.ts create mode 100644 apps/app-frontend/src/helpers/users.ts create mode 100644 apps/app-frontend/src/pages/instance/share/index.vue create mode 100644 apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue create mode 100644 apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue create mode 100644 apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue create mode 100644 apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts create mode 100644 apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-candidates.ts create mode 100644 apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts create mode 100644 apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts create mode 100644 apps/app-frontend/src/pages/instance/use-shared-instance-state.ts create mode 100644 apps/app-frontend/src/providers/instance-backup.ts create mode 100644 apps/app/src/api/reports.rs create mode 100644 apps/app/src/api/users.rs create mode 100644 apps/frontend/src/components/ui/SharedInstanceInviteOpenInAppModal.vue create mode 100644 apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue create mode 100644 apps/frontend/src/pages/share/[inviteId].vue create mode 100644 packages/api-client/src/modules/shared-instances/instances/v1.ts create mode 100644 packages/api-client/src/modules/shared-instances/invites/v1.ts create mode 100644 packages/api-client/src/modules/shared-instances/moderation/v1.ts create mode 100644 packages/api-client/src/modules/shared-instances/types.ts create mode 100644 packages/api-client/src/modules/shared-instances/users/v1.ts create mode 100644 packages/app-lib/.sqlx/query-0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f.json create mode 100644 packages/app-lib/.sqlx/query-2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc.json delete mode 100644 packages/app-lib/.sqlx/query-474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7.json create mode 100644 packages/app-lib/.sqlx/query-6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57.json rename packages/app-lib/.sqlx/{query-26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281.json => query-6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa.json} (73%) create mode 100644 packages/app-lib/.sqlx/query-76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652.json create mode 100644 packages/app-lib/.sqlx/query-88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a.json create mode 100644 packages/app-lib/.sqlx/query-aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04.json create mode 100644 packages/app-lib/.sqlx/query-b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c.json delete mode 100644 packages/app-lib/.sqlx/query-bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d.json create mode 100644 packages/app-lib/.sqlx/query-becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243.json create mode 100644 packages/app-lib/.sqlx/query-bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b.json delete mode 100644 packages/app-lib/.sqlx/query-e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306.json delete mode 100644 packages/app-lib/.sqlx/query-eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8.json delete mode 100644 packages/app-lib/.sqlx/query-f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b.json create mode 100644 packages/app-lib/migrations/20260630120000_shared-instance-role.sql create mode 100644 packages/app-lib/migrations/20260707103000_shared-instance-manager.sql create mode 100644 packages/app-lib/migrations/20260707110000_shared-instance-linked-user.sql create mode 100644 packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql create mode 100644 packages/app-lib/migrations/20260723120000_quarantined-instances.sql create mode 100644 packages/app-lib/src/api/instance/content_set_diff.rs create mode 100644 packages/app-lib/src/api/instance/shared/client.rs create mode 100644 packages/app-lib/src/api/instance/shared/diff.rs create mode 100644 packages/app-lib/src/api/instance/shared/install.rs create mode 100644 packages/app-lib/src/api/instance/shared/invites.rs create mode 100644 packages/app-lib/src/api/instance/shared/mod.rs create mode 100644 packages/app-lib/src/api/instance/shared/publish.rs create mode 100644 packages/app-lib/src/api/instance/shared/types.rs create mode 100644 packages/app-lib/src/api/reports.rs create mode 100644 packages/app-lib/src/api/users.rs create mode 100644 packages/app-lib/src/install/shared_instance.rs create mode 100644 packages/app-lib/src/state/instances/commands/shared_instance.rs create mode 100644 packages/assets/branding/illustrations/invite-bg.webp create mode 100644 packages/assets/icons/file-cog.svg delete mode 100644 packages/ui/src/components/modal/InstallToPlayModal.vue create mode 100644 packages/ui/src/components/sharing/index.ts create mode 100644 packages/ui/src/components/sharing/invite-players-modal/index.vue create mode 100644 packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-invite-link-editor.vue create mode 100644 packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue create mode 100644 packages/ui/src/components/sharing/invite-players-modal/types.ts create mode 100644 packages/ui/src/components/sharing/invite-players-modal/use-invite-players-search.ts create mode 100644 packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmDisableModal.vue create mode 100644 packages/ui/src/stories/sharing/InvitePlayersModal.stories.ts diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 90dd210d5..7155f1339 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -78,14 +78,15 @@ import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWar import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue' import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' -import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue' import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue' +import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' import NavButton from '@/components/ui/NavButton.vue' import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' +import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue' import SplashScreen from '@/components/ui/SplashScreen.vue' import WindowControls from '@/components/ui/WindowControls.vue' import { useCheckDisableMouseover } from '@/composables/macCssFix.js' @@ -103,8 +104,13 @@ import { check_reachable } from '@/helpers/auth.js' import { get_user, get_version } from '@/helpers/cache.js' import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js' import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' -import { list, run } from '@/helpers/instance' -import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' +import { + can_current_user_use_shared_instances, + get as getInstance, + list, + run, +} from '@/helpers/instance' +import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts' import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' @@ -155,6 +161,7 @@ const APP_SIDEBAR_WIDTH = 300 const INTERCOM_BUBBLE_DEFAULT_PADDING = 20 const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime() const credentials = ref() +let credentialsRefreshId = 0 const sidebarToggled = ref(true) const unsubscribeSidebarToggle = themeStore.$subscribe(() => { sidebarToggled.value = !themeStore.toggleSidebar @@ -200,6 +207,7 @@ const tauriApiClient = new TauriModrinthClient({ userAgent: async () => `modrinth/theseus/${await appVersion} (support@modrinth.com)`, labrinthBaseUrl: config.labrinthBaseUrl, archonBaseUrl: config.archonBaseUrl, + sharedInstancesBaseUrl: config.sharedInstancesBaseUrl, features: [ new NodeAuthFeature({ getAuth: () => nodeAuthState.getAuth?.() ?? null, @@ -223,6 +231,16 @@ const { data: authenticatedModrinthUser } = useQuery({ enabled: () => !!credentials.value?.session, retry: false, }) +useQuery({ + queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]), + queryFn: can_current_user_use_shared_instances, + enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id, + retry: false, + staleTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +}) const hasPlus = computed( () => !!credentials.value?.user && @@ -735,9 +753,10 @@ const contentInstallModpackAlreadyInstalledModal = ref() const addServerToInstanceModal = ref() const incompatibilityWarningModal = ref() const installToPlayModal = ref() +const sharedInstanceInviteHandler = ref() const updateToPlayModal = ref() -const modrinthLoginFlowWaitModal = ref() +const modrinthLoginModal = ref() watch(incompatibilityWarningModal, (modal) => { if (modal) { @@ -745,8 +764,12 @@ watch(incompatibilityWarningModal, (modal) => { } }) -setupAuthProvider(credentials, async (_redirectPath) => { - await signIn() +setupAuthProvider(credentials, async (_redirectPath, flow, options) => { + if (options?.showModal === false) { + await signIn(flow) + } else { + await requestSignIn(flow) + } }) async function validateSession(sessionToken) { @@ -763,23 +786,31 @@ async function validateSession(sessionToken) { } async function fetchCredentials() { + const refreshId = ++credentialsRefreshId + credentials.value = undefined + const creds = await getCreds().catch(handleError) + if (refreshId !== credentialsRefreshId) return + if (creds && creds.user_id) { if (creds.session && !(await validateSession(creds.session))) { + if (refreshId !== credentialsRefreshId) return + await logout().catch(handleError) + if (refreshId !== credentialsRefreshId) return + credentials.value = null return } creds.user = await get_user(creds.user_id, 'bypass').catch(handleError) + if (refreshId !== credentialsRefreshId) return } credentials.value = creds ?? null } -async function signIn() { - modrinthLoginFlowWaitModal.value.show() - +async function signIn(flow = 'sign-in') { try { - await login() + await login(flow) await fetchCredentials() } catch (error) { if ( @@ -791,12 +822,26 @@ async function signIn() { } else { handleError(error) } - } finally { - modrinthLoginFlowWaitModal.value.hide() } } +async function requestSignIn(flow = 'sign-in') { + await modrinthLoginModal.value?.showSigningIn(flow) +} + +async function requestModrinthAuth(flow = 'sign-in') { + await signIn(flow) + return !!credentials.value?.session +} + async function logOut() { + await performLogOut() +} + +async function performLogOut() { + credentialsRefreshId++ + credentials.value = undefined + await logout().catch(handleError) await fetchCredentials() } @@ -917,30 +962,34 @@ function openServerInviteInviterProfile(inviterName) { } async function handleLiveNotification(notification) { - if (notification?.body?.type !== 'server_invite' || notification.read) return - if (displayedServerInviteNotifications.has(notification.id)) return + if (!notification?.body || notification.read) return + if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) return - displayedServerInviteNotifications.add(notification.id) + if (notification.body.type === 'server_invite') { + if (displayedServerInviteNotifications.has(notification.id)) return - const serverName = - typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server' - const inviterId = notification.body.invited_by - const invitedBy = - typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null + displayedServerInviteNotifications.add(notification.id) - addPopupNotification({ - title: serverName, - autoCloseMs: null, - toast: { - type: 'server-invite', - actorName: invitedBy?.username ?? null, - actorAvatarUrl: invitedBy?.avatar_url ?? null, - entityName: serverName, - onAccept: () => acceptServerInviteNotification(notification), - onDecline: () => declineServerInviteNotification(notification), - onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null), - }, - }) + const serverName = + typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server' + const inviterId = notification.body.invited_by + const invitedBy = + typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null + + addPopupNotification({ + title: serverName, + autoCloseMs: null, + toast: { + type: 'server-invite', + actorName: invitedBy?.username ?? null, + actorAvatarUrl: invitedBy?.avatar_url ?? null, + entityName: serverName, + onAccept: () => acceptServerInviteNotification(notification), + onDecline: () => declineServerInviteNotification(notification), + onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null), + }, + }) + } } async function handleCommand(e) { @@ -967,6 +1016,9 @@ async function handleCommand(e) { }) } } else if (e.event === 'LaunchInstance') { + const instance = await getInstance(e.id).catch(handleError) + if (!instance || instance.quarantined) return + if (e.server) { await start_join_server(e.id, e.server).catch(handleError) } else if (e.singleplayer_world) { @@ -974,6 +1026,8 @@ async function handleCommand(e) { } else { await run(e.id).catch(handleError) } + } else if (e.event === 'InstallSharedInstanceInvite') { + await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id) } else if (e.event === 'InstallServer') { await router.push(`/project/${e.id}`) await playServerProject(e.id).catch(handleError) @@ -1498,7 +1552,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) - + - + @@ -1749,7 +1807,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
- +
+ diff --git a/apps/app-frontend/src/components/GridDisplay.vue b/apps/app-frontend/src/components/GridDisplay.vue index 6b9177ed6..5004b927b 100644 --- a/apps/app-frontend/src/components/GridDisplay.vue +++ b/apps/app-frontend/src/components/GridDisplay.vue @@ -65,8 +65,7 @@ async function duplicateInstance(p) { const handleRightClick = (event, instanceId) => { const item = instanceComponents.value.find((x) => x.instance.id === instanceId) const baseOptions = [ - { name: 'add_content' }, - { type: 'divider' }, + ...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]), { name: 'edit' }, { name: 'duplicate' }, { name: 'open' }, @@ -90,10 +89,14 @@ const handleRightClick = (event, instanceId) => { ...baseOptions, ] : [ - { - name: 'play', - color: 'primary', - }, + ...(item.instance.quarantined + ? [] + : [ + { + name: 'play', + color: 'primary', + }, + ]), ...baseOptions, ], ) diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue index 02b1a2a40..982b2ad0b 100644 --- a/apps/app-frontend/src/components/RowDisplay.vue +++ b/apps/app-frontend/src/components/RowDisplay.vue @@ -73,8 +73,7 @@ async function duplicateInstance(p) { const handleInstanceRightClick = async (event, passedInstance) => { const baseOptions = [ - { name: 'add_content' }, - { type: 'divider' }, + ...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]), { name: 'edit' }, { name: 'duplicate' }, { name: 'open_folder' }, @@ -98,10 +97,14 @@ const handleInstanceRightClick = async (event, passedInstance) => { ...baseOptions, ] : [ - { - name: 'play', - color: 'primary', - }, + ...(passedInstance.quarantined + ? [] + : [ + { + name: 'play', + color: 'primary', + }, + ]), ...baseOptions, ] diff --git a/apps/app-frontend/src/components/ui/Instance.vue b/apps/app-frontend/src/components/ui/Instance.vue index 9fff27fa3..6845a00f2 100644 --- a/apps/app-frontend/src/components/ui/Instance.vue +++ b/apps/app-frontend/src/components/ui/Instance.vue @@ -66,6 +66,7 @@ const checkProcess = async () => { const play = async (e, context) => { e?.stopPropagation() + if (props.instance.quarantined) return loading.value = true await run(props.instance.id) .catch((err) => handleSevereError(err, { instanceId: props.instance.id })) @@ -94,6 +95,7 @@ const stop = async (e, context) => { const repair = async (e) => { e?.stopPropagation() + if (props.instance.quarantined) return if ( props.instance.install_stage !== 'pack_installed' && @@ -116,6 +118,7 @@ const openFolder = async () => { } const addContent = async () => { + if (props.instance.quarantined) return await router.push({ path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`, query: { i: props.instance.id }, @@ -142,7 +145,9 @@ const unlisten = await process_listener((e) => { } }) -onMounted(() => checkProcess()) +onMounted(() => { + checkProcess() +}) onUnmounted(() => unlisten()) @@ -173,7 +178,11 @@ onUnmounted(() => unlisten()) - + - + - - - + + + + + + (), { iconSrc: null, @@ -212,6 +287,7 @@ const props = withDefaults( ping: undefined, minecraftServer: undefined, linkedProjectV3: undefined, + sharedInstanceManager: null, }, ) @@ -224,6 +300,7 @@ const emit = defineEmits<{ openFolder: [] export: [] createShortcut: [] + report: [event?: MouseEvent] }>() const installingStages = [ @@ -241,6 +318,21 @@ const loaderDisplayName = computed(() => formatLoaderLabel(props.instance.loader const loaderLabel = computed(() => [loaderDisplayName.value, props.instance.loader_version].filter(Boolean).join(' '), ) +const sharedInstanceTooltip = computed(() => + formatMessage( + props.instance.shared_instance?.role === 'owner' + ? messages.sharedInstanceOwnerTooltip + : messages.sharedInstanceTooltip, + ), +) +const sharedInstanceManagerLabel = computed(() => + props.sharedInstanceManager?.type === 'server' ? 'Linked to' : 'Managed by', +) +const sharedInstanceManagerAction = computed(() => { + const manager = props.sharedInstanceManager + if (manager?.type !== 'user') return undefined + return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(manager.name)}`) +}) const playtimeLabel = computed(() => { if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed) @@ -271,24 +363,46 @@ const serverPlayActions = computed(() => [ action: () => emit('play'), }, ]) -const moreActions = computed(() => [ - { - id: 'open-folder', - label: formatMessage(messages.openFolder), - icon: FolderOpenIcon, - action: () => emit('openFolder'), - }, - { - id: 'export-mrpack', - label: formatMessage(messages.exportModpack), - icon: PackageIcon, - action: () => emit('export'), - }, - { - id: 'create-shortcut', - label: formatMessage(messages.createShortcut), - icon: ExternalIcon, - action: () => emit('createShortcut'), - }, -]) +const moreActions = computed(() => { + const actions: TeleportOverflowMenuItem[] = [ + { + id: 'open-folder', + label: formatMessage(messages.openFolder), + icon: FolderOpenIcon, + action: () => emit('openFolder'), + }, + ] + + if (!props.instance.quarantined) { + actions.push( + { + id: 'export-mrpack', + label: formatMessage(messages.exportModpack), + icon: PackageIcon, + action: () => emit('export'), + }, + { + id: 'create-shortcut', + label: formatMessage(messages.createShortcut), + icon: ExternalIcon, + action: () => emit('createShortcut'), + }, + ) + } + + if (props.instance.shared_instance?.role === 'member') { + actions.push( + { divider: true }, + { + id: 'report-shared-instance', + label: formatMessage(commonMessages.reportButton), + icon: ReportIcon, + color: 'red', + action: (event) => emit('report', event), + }, + ) + } + + return actions +}) diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue new file mode 100644 index 000000000..4d73ef688 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue @@ -0,0 +1,116 @@ + + + diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts new file mode 100644 index 000000000..5191ae9b2 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts @@ -0,0 +1,69 @@ +import { defineMessages } from '@modrinth/ui' + +export const instanceAdmonitionsMessages = defineMessages({ + sharedInstanceChangesHeader: { + id: 'app.instance.admonitions.shared-instance.changes-header', + defaultMessage: "Your changes haven't been shared yet", + }, + sharedInstanceChangesBody: { + id: 'app.instance.admonitions.shared-instance.changes-body', + defaultMessage: "Your local instance is ahead of the users you've shared it with.", + }, + sharedInstancePublishButton: { + id: 'app.instance.admonitions.shared-instance.publish-button', + defaultMessage: 'Push update', + }, + sharedInstancePublishingButton: { + id: 'app.instance.admonitions.shared-instance.publishing-button', + defaultMessage: 'Pushing...', + }, + sharedInstanceReviewingButton: { + id: 'app.instance.admonitions.shared-instance.reviewing-button', + defaultMessage: 'Reviewing...', + }, + sharedInstanceReviewHeader: { + id: 'app.instance.admonitions.shared-instance.review-header', + defaultMessage: 'Review changes', + }, + sharedInstanceReviewAdmonitionHeader: { + id: 'app.instance.admonitions.shared-instance.review-admonition-header', + defaultMessage: 'Push update', + }, + sharedInstanceReviewDescription: { + id: 'app.instance.admonitions.shared-instance.review-description', + defaultMessage: + 'Review the content changes that will be shared with everyone using this instance.', + }, + sharedInstanceAddedLabel: { + id: 'app.instance.admonitions.shared-instance.added-label', + defaultMessage: 'Added', + }, + sharedInstanceRemovedLabel: { + id: 'app.instance.admonitions.shared-instance.removed-label', + defaultMessage: 'Removed', + }, + sharedInstanceWrongAccountHeader: { + id: 'app.instance.shared-instance-wrong-account.warning-header', + defaultMessage: 'You are using the wrong Modrinth account', + }, + sharedInstanceSignedOutHeader: { + id: 'app.instance.shared-instance-wrong-account.signed-out-header', + defaultMessage: 'You need to sign in to Modrinth', + }, + sharedInstanceWrongAccountSignInAs: { + id: 'app.instance.shared-instance-wrong-account.sign-in-as-label', + defaultMessage: 'Sign in as', + }, + sharedInstanceWrongAccountUserBody: { + id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2', + defaultMessage: 'to receive updates for this shared instance.', + }, + sharedInstanceWrongAccountOwnerBody: { + id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2', + defaultMessage: "to manage this shared instance. You won't be able to push updates to users.", + }, + sharedInstanceWrongAccountFallbackUsername: { + id: 'app.instance.shared-instance-wrong-account.fallback-username', + defaultMessage: 'the linked account', + }, +}) diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue new file mode 100644 index 000000000..e437bfe1c --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue @@ -0,0 +1,66 @@ + + + diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue new file mode 100644 index 000000000..88dc3b715 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue @@ -0,0 +1,51 @@ + + + diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue new file mode 100644 index 000000000..aab1f2fa8 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts b/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts new file mode 100644 index 000000000..ea7eb81be --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts @@ -0,0 +1,12 @@ +import type { StackedAdmonitionItem } from '@modrinth/ui' + +export type InstanceAdmonitionKind = + | 'shared-instance-stale' + | 'shared-instance-unavailable' + | 'shared-instance-wrong-account' + +export type InstanceAdmonitionItem = StackedAdmonitionItem & { + kind: InstanceAdmonitionKind +} + +export type SharedInstanceRole = 'owner' | 'member' diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue index fa1a4d031..fe210e165 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue @@ -7,7 +7,6 @@ import { injectFilePicker, injectNotificationManager, InstallationSettingsLayout, - provideAppBackup, provideInstallationSettings, useDebugLogger, useVIntl, @@ -16,24 +15,25 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, ref } from 'vue' +import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue' +import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy' import { trackEvent } from '@/helpers/analytics' import { get_project_versions, get_version } from '@/helpers/cache' import { - install_duplicate_instance, install_existing_instance, install_pack_to_existing_instance, - installJobInstanceId, wait_for_install_job, } from '@/helpers/install' import { edit, get_linked_modpack_info, - list, + unlink_shared_instance, update_managed_modrinth_version, update_repair_modrinth, } from '@/helpers/instance' import { get_loader_versions } from '@/helpers/metadata' import { get_game_versions, get_loaders } from '@/helpers/tags' +import { provideInstanceBackup } from '@/providers/instance-backup' import { injectInstanceSettings } from '@/providers/instance-settings' import { useTheming } from '@/store/state' @@ -47,6 +47,7 @@ const debug = useDebugLogger('AppInstallationSettings') const themeStore = useTheming() const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings() +const managedContentPolicy = useManagedContentPolicy(instance) const skipNonEssentialWarnings = computed(() => themeStore.getFeatureFlag('skip_non_essential_warnings'), ) @@ -111,9 +112,14 @@ debug('metadata queries configured', { const isModrinthLinkedModpack = computed( () => instance.value.link?.type === 'modrinth_modpack' || - instance.value.link?.type === 'server_project_modpack', + instance.value.link?.type === 'server_project_modpack' || + (instance.value.link?.type === 'shared_instance' && + !!instance.value.link.modpack_project_id && + !!instance.value.link.modpack_version_id), ) const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack') +const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack +const canUnlinkSharedInstance = managedContentPolicy.canUnlink const modpackInfoQuery = useQuery({ queryKey: computed(() => ['linkedModpackInfo', instance.value.id]), @@ -124,12 +130,43 @@ const modpackInfo = modpackInfoQuery.data const repairing = ref(false) const reinstalling = ref(false) +const unlinkingSharedInstance = ref(false) +const installationSettingsBusy = computed( + () => + instance.value.quarantined || + instance.value.install_stage !== 'installed' || + repairing.value || + reinstalling.value || + unlinkingSharedInstance.value || + !!offline, +) +const installationSettingsBusyMessage = computed(() => + instance.value.quarantined ? formatMessage(messages.locked) : null, +) + +async function unlinkSharedInstance() { + unlinkingSharedInstance.value = true + try { + await unlink_shared_instance(instance.value.id) + await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] }) + await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] }) + onUnlinked() + } catch (error) { + handleError(error) + } finally { + unlinkingSharedInstance.value = false + } +} const messages = defineMessages({ loaderVersion: { id: 'instance.settings.tabs.installation.loader-version', defaultMessage: '{loader} version', }, + locked: { + id: 'instance.settings.tabs.installation.locked', + defaultMessage: 'Installation settings are unavailable while this instance is locked.', + }, }) function getManifest(loader: string) { @@ -162,27 +199,7 @@ async function installLocalModpackFromPicker() { return !!completed } -provideAppBackup({ - async createBackup() { - debug('createBackup: start', { - instanceId: instance.value.id, - instanceName: instance.value.name, - }) - const allInstances = await list() - const prefix = `${instance.value.name} - Backup #` - const existingNums = allInstances - .filter((p) => p.name.startsWith(prefix)) - .map((p) => parseInt(p.name.slice(prefix.length), 10)) - .filter((n) => !isNaN(n)) - const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1 - const job = await install_duplicate_instance(instance.value.id) - const newInstanceId = installJobInstanceId(job) - if (newInstanceId) { - await edit(newInstanceId, { name: `${prefix}${nextNum}` }) - } - debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` }) - }, -}) +provideInstanceBackup(instance) provideInstallationSettings({ closeSettings: closeModal, @@ -208,14 +225,14 @@ provideInstallationSettings({ } return rows }), - isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value), - isBusy: computed( + isLinked: computed( () => - instance.value.install_stage !== 'installed' || - repairing.value || - reinstalling.value || - !!offline, + isModrinthLinkedModpack.value || + isImportedModpack.value || + isSharedInstanceManagedModpack.value, ), + isBusy: installationSettingsBusy, + busyMessage: installationSettingsBusyMessage, skipNonEssentialWarnings, modpack: computed(() => { if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') { @@ -452,14 +469,28 @@ provideInstallationSettings({ isServer: false, isApp: true, showModpackVersionActions: computed( - () => isModrinthLinkedModpack.value && !isMinecraftServer.value, + () => + isModrinthLinkedModpack.value && + !isMinecraftServer.value && + !isSharedInstanceManagedModpack.value, ), isLocalFile: isImportedModpack, + isManagedModpack: isSharedInstanceManagedModpack, + managedModpackWarning: managedContentPolicy.managedModpackWarning, repairing, reinstalling, }) diff --git a/apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue new file mode 100644 index 000000000..aef391eea --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue @@ -0,0 +1,42 @@ + + + diff --git a/apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue b/apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue deleted file mode 100644 index 03f3036f9..000000000 --- a/apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue +++ /dev/null @@ -1,43 +0,0 @@ - - diff --git a/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue index 31ccce12f..912725f5e 100644 --- a/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue @@ -4,8 +4,8 @@ :header="formatMessage(messages.installToPlay)" :closable="true" :on-hide="show_ads_window" - max-width="640px" - width="640px" + max-width="544px" + width="544px" >

@@ -423,8 +423,8 @@ const messages = defineMessages({ defaultMessage: 'Install anyway', }, dontInstall: { - id: 'app.modal.install-to-play.dont-install', - defaultMessage: 'Dont install', + id: 'app.modal.install-to-play.external-files-dont-install', + defaultMessage: "Don't install", }, }) diff --git a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue index bedef146b..800cac656 100644 --- a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue @@ -6,6 +6,7 @@ import { CoffeeIcon, InfoIcon, MonitorIcon, + UsersIcon, WrenchIcon, } from '@modrinth/assets' import { @@ -25,6 +26,7 @@ import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.v import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue' import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue' import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue' +import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue' import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue' import { get_project_v3 } from '@/helpers/cache' import { get_linked_modpack_info } from '@/helpers/instance' @@ -97,6 +99,15 @@ const tabs = computed(() => [ icon: WrenchIcon, content: InstallationSettings, }, + { + name: defineMessage({ + id: 'instance.settings.tabs.sharing', + defaultMessage: 'Sharing', + }), + icon: UsersIcon, + content: SharingSettings, + shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined, + }, { name: defineMessage({ id: 'instance.settings.tabs.window', diff --git a/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue new file mode 100644 index 000000000..db5dbc880 --- /dev/null +++ b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue @@ -0,0 +1,265 @@ + + + diff --git a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue index 9c756e6d6..ba5cff8b2 100644 --- a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue +++ b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue @@ -8,6 +8,7 @@ :diffs="normalizedDiffs" :version-date="versionDate" :show-external-warnings="showExternalWarnings" + :external-warning-description="formatMessage(messages.externalWarningDescription)" :confirm-label="formatMessage(commonMessages.updateButton)" :confirm-icon="DownloadIcon" :removed-label="formatMessage(messages.removed)" @@ -76,10 +77,15 @@ const { formatMessage } = useVIntl() const { startInstallingServer, stopInstallingServer } = injectServerInstall() type UpdateCompleteCallback = () => void | Promise -defineProps<{ +const { showExternalWarnings } = defineProps<{ showExternalWarnings?: boolean }>() +const emit = defineEmits<{ + cancel: [] + complete: [] +}>() + const diffModal = ref>() const instance = ref(null) const onUpdateComplete = ref(() => {}) @@ -87,16 +93,16 @@ const diffs = ref([]) const modpackVersionId = ref(null) const modpackVersion = ref(null) -const normalizedDiffs = computed(() => - diffs.value.map((diff) => ({ +const normalizedDiffs = computed(() => { + return diffs.value.map((diff) => ({ type: diff.type, external: Boolean(diff.fileName && !diff.project), projectName: diff.project?.title, fileName: diff.fileName, currentVersionName: diff.currentVersion?.version_number, newVersionName: diff.newVersion?.version_number, - })), -) + })) +}) const versionDate = computed(() => modpackVersion.value?.date_published @@ -242,7 +248,6 @@ async function checkUpdateAvailable(inst: GameInstance): Promise { diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue new file mode 100644 index 000000000..3e5922027 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue @@ -0,0 +1,100 @@ + + + diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue new file mode 100644 index 000000000..d72f17090 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue @@ -0,0 +1,195 @@ + + + diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue new file mode 100644 index 000000000..05b9ba6e8 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue @@ -0,0 +1,189 @@ + + + diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue new file mode 100644 index 000000000..4767a6ff0 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue @@ -0,0 +1,131 @@ + + + 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 new file mode 100644 index 000000000..e4ebfd949 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue @@ -0,0 +1,533 @@ + + + diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue new file mode 100644 index 000000000..8db6f7abb --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue @@ -0,0 +1,60 @@ + + + diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts new file mode 100644 index 000000000..671b4b189 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts @@ -0,0 +1,135 @@ +import type { Labrinth } from '@modrinth/api-client' +import type { ContentItem } from '@modrinth/ui' + +import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js' +import type { SharedInstanceInstallPreview } from '@/helpers/install' + +type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string } + +export function useSharedInstancePreviewContent() { + async function load(preview: SharedInstanceInstallPreview): Promise { + return [ + ...preview.externalFiles.map(externalFileContentItem), + ...(await modpackContentItems(preview)), + ...(await contentItemsFromVersionIds( + preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId), + )), + ] + } + + async function modpackContentItems(preview: SharedInstanceInstallPreview) { + if (!preview.modpackVersionId) return [] + const version = await get_version(preview.modpackVersionId, 'must_revalidate') + return await contentItemsFromDependencies(version?.dependencies ?? []) + } + + async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) { + const deps = dependencies as VersionDependency[] + const projectIds = unique(deps.map((dep) => dep.project_id).filter((id): id is string => !!id)) + const versionIds = unique(deps.map((dep) => dep.version_id).filter((id): id is string => !!id)) + const [projects, versions]: [Labrinth.Projects.v2.Project[], Labrinth.Versions.v2.Version[]] = + await Promise.all([ + projectIds.length ? get_project_many(projectIds, 'must_revalidate') : [], + versionIds.length ? get_version_many(versionIds, 'must_revalidate') : [], + ]) + const projectMap = new Map(projects.map((project) => [project.id, project])) + const versionMap = new Map(versions.map((version) => [version.id, version])) + + return deps.map((dependency): ContentItem => { + const project = dependency.project_id ? projectMap.get(dependency.project_id) : null + const version = dependency.version_id ? versionMap.get(dependency.version_id) : null + const fileName = + version?.files?.[0]?.filename ?? dependency.file_name ?? project?.title ?? 'Unknown' + return contentItem( + version?.id ?? project?.id ?? fileName, + fileName, + project, + version, + !project && !version, + dependency.project_id ?? fileName, + dependency.file_name ?? fileName, + ) + }) + } + + async function contentItemsFromVersionIds(versionIds: string[]) { + const versions: Labrinth.Versions.v2.Version[] = versionIds.length + ? await get_version_many(unique(versionIds), 'must_revalidate') + : [] + const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean)) + const projects: Labrinth.Projects.v2.Project[] = projectIds.length + ? await get_project_many(projectIds, 'must_revalidate') + : [] + const projectMap = new Map(projects.map((project) => [project.id, project])) + return versions.map((version): ContentItem => { + const project = projectMap.get(version.project_id) + const fileName = version.files?.[0]?.filename ?? project?.title ?? version.name ?? 'Unknown' + return contentItem( + version.id, + fileName, + project, + version, + false, + version.project_id, + version.name, + ) + }) + } + + return { load } +} + +function contentItem( + id: string, + fileName: string, + project?: Labrinth.Projects.v2.Project | null, + version?: Labrinth.Versions.v2.Version | null, + external = false, + fallbackProjectId = id, + fallbackTitle = fileName, + projectType = project?.project_type ?? 'mod', +): ContentItem { + return { + id, + file_name: fileName, + project_type: projectType, + has_update: false, + update_version_id: null, + external, + project: { + id: project?.id ?? fallbackProjectId, + slug: project?.slug ?? fallbackProjectId, + title: project?.title ?? fallbackTitle, + icon_url: project?.icon_url ?? undefined, + }, + ...(version + ? { + version: { + id: version.id, + file_name: fileName, + version_number: version.version_number ?? undefined, + date_published: version.date_published ?? undefined, + }, + } + : {}), + } +} + +function externalFileContentItem( + file: SharedInstanceInstallPreview['externalFiles'][number], +): ContentItem { + return contentItem( + `external:${file.fileType}:${file.fileName}`, + file.fileName, + null, + null, + true, + file.fileName, + file.fileName, + file.fileType, + ) +} + +function unique(values: T[]) { + return Array.from(new Set(values)) +} diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue new file mode 100644 index 000000000..2229707f4 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts new file mode 100644 index 000000000..60803ee4a --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts @@ -0,0 +1,29 @@ +import type { + AppNotification, + SharedInstanceInvite, + SharedInstanceInviteNotificationBody, +} from './shared-instance-invite-types' + +function optionalString(value: unknown) { + return typeof value === 'string' ? value : null +} + +export function parseSharedInstanceInviteNotification( + notification: AppNotification, +): SharedInstanceInvite | null { + if (notification.body?.type !== 'shared_instance_invite') return null + + const body = notification.body as SharedInstanceInviteNotificationBody + const sharedInstanceId = optionalString(body.shared_instance_id) + const sharedInstanceName = optionalString(body.shared_instance_name) + if (!sharedInstanceId || !sharedInstanceName) return null + + return { + sharedInstanceId, + sharedInstanceName, + invitedById: optionalString(body.invited_by), + invitedByUsername: optionalString(body.invited_by_username), + invitedByAvatarUrl: optionalString(body.invited_by_avatar_url), + instanceIconUrl: optionalString(body.shared_instance_icon), + } +} diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts new file mode 100644 index 000000000..0227b4542 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts @@ -0,0 +1,29 @@ +export type SharedInstanceInviteNotificationBody = { + type: 'shared_instance_invite' + shared_instance_id?: unknown + shared_instance_name?: unknown + invited_by?: unknown + invited_by_username?: unknown + invited_by_avatar_url?: unknown + shared_instance_icon?: unknown +} + +export type AppNotification = { + id: string | number + read?: boolean + body?: { type?: unknown } & Record +} + +export type SharedInstanceInvite = { + sharedInstanceId: string + sharedInstanceName: string + invitedById: string | null + invitedByUsername: string | null + invitedByAvatarUrl: string | null + instanceIconUrl: string | null +} + +export type SharedInstanceInviteHandler = { + handleNotification(notification: AppNotification): Promise + installFromInviteId(inviteId: string): Promise +} diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts new file mode 100644 index 000000000..58f4c1480 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts @@ -0,0 +1,252 @@ +import { ModrinthApiError } from '@modrinth/api-client' +import { + injectAuth, + injectModrinthClient, + injectNotificationManager, + injectPopupNotificationManager, +} from '@modrinth/ui' +import { useQueryClient } from '@tanstack/vue-query' +import { openUrl } from '@tauri-apps/plugin-opener' +import { type Ref, watch } from 'vue' +import { useRouter } from 'vue-router' + +import { config } from '@/config' +import { get_user } from '@/helpers/cache' +import { toError } from '@/helpers/errors' +import { + install_accept_shared_instance_invite, + install_get_shared_instance_preview, + install_shared_instance, +} from '@/helpers/install' +import { list } from '@/helpers/instance' +import { useTheming } from '@/store/state' + +import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser' +import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types' + +type InstallModal = { + show( + preview: Awaited>, + install: () => Promise, + ): void +} + +type AccountRequiredModal = { + show(event?: MouseEvent): Promise +} + +type AlreadyInstalledModal = { + show(instanceName: string): void +} + +export function useSharedInstanceInviteHandler( + installModal: Ref, + alreadyInstalledModal: Ref, + accountRequiredModal: Ref, +) { + const auth = injectAuth() + const client = injectModrinthClient() + const { handleError } = injectNotificationManager() + const { addPopupNotification } = injectPopupNotificationManager() + const queryClient = useQueryClient() + const router = useRouter() + const themeStore = useTheming() + const displayedNotifications = new Set() + let pendingAlreadyInstalled: + | { + instanceId: string + preview: Awaited> + install: () => Promise + onGoToInstance?: () => void | Promise + } + | undefined + + async function markNotificationRead(notification: AppNotification) { + try { + await client.labrinth.notifications_v2.markAsRead(String(notification.id)) + } catch (error) { + if (error instanceof ModrinthApiError && error.statusCode === 404) return + throw error + } + } + + async function resolveInvite(invite: SharedInstanceInvite) { + const [invitedBy, sharedInstance] = await Promise.all([ + !invite.invitedByUsername && invite.invitedById + ? get_user(invite.invitedById, 'bypass').catch(() => null) + : null, + client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null), + ]) + + return { + ...invite, + invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null, + invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null, + instanceIconUrl: sharedInstance ? sharedInstance.icon : invite.instanceIconUrl, + } + } + + function showInstall( + preview: Awaited>, + install: () => Promise, + ) { + if (!installModal.value) throw new Error('Shared instance install modal is not available.') + installModal.value.show(preview, install) + } + + async function showInstallOrAlreadyInstalled( + sharedInstanceId: string, + preview: Awaited>, + install: () => Promise, + onGoToInstance?: () => void | Promise, + ) { + const existingInstance = (await list()).find( + (instance) => instance.shared_instance?.id === sharedInstanceId, + ) + + if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) { + showInstall(preview, install) + return + } + + if (!alreadyInstalledModal.value) { + throw new Error('Shared instance already installed modal is not available.') + } + + pendingAlreadyInstalled = { + instanceId: existingInstance.id, + preview, + install, + onGoToInstance, + } + alreadyInstalledModal.value.show(existingInstance.name) + } + + function handleAlreadyInstalledCancel() { + pendingAlreadyInstalled = undefined + } + + async function handleAlreadyInstalledGoToInstance() { + const pending = pendingAlreadyInstalled + pendingAlreadyInstalled = undefined + if (!pending) return + + if (pending.onGoToInstance) { + try { + await pending.onGoToInstance() + } catch (error) { + handleError(toError(error)) + } + } + await router.push(`/instance/${encodeURIComponent(pending.instanceId)}/`) + } + + function handleAlreadyInstalledInstallAnyway() { + const pending = pendingAlreadyInstalled + pendingAlreadyInstalled = undefined + if (!pending) return + showInstall(pending.preview, pending.install) + } + + async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) { + try { + const preview = await install_get_shared_instance_preview( + invite.sharedInstanceId, + invite.sharedInstanceName, + ) + if (invite.instanceIconUrl) preview.iconUrl = invite.instanceIconUrl + + await showInstallOrAlreadyInstalled( + invite.sharedInstanceId, + preview, + async () => { + await install_shared_instance( + invite.sharedInstanceId, + invite.sharedInstanceName, + invite.invitedById, + null, + null, + invite.instanceIconUrl, + ) + await markNotificationRead(notification) + await queryClient.invalidateQueries({ queryKey: ['instances'] }) + }, + () => markNotificationRead(notification), + ) + } catch (error) { + handleError(toError(error)) + } + } + + async function handleNotification(notification: AppNotification) { + const parsedInvite = parseSharedInstanceInviteNotification(notification) + if (!parsedInvite) return false + if (displayedNotifications.has(notification.id)) return true + + displayedNotifications.add(notification.id) + const invite = await resolveInvite(parsedInvite) + addPopupNotification({ + title: invite.sharedInstanceName, + autoCloseMs: null, + toast: { + type: 'instance-invite', + actorName: invite.invitedByUsername, + actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined, + entityName: invite.sharedInstanceName, + entityIconUrl: invite.instanceIconUrl ?? undefined, + onAccept: () => acceptNotification(notification, invite), + onDecline: () => + markNotificationRead(notification).catch((error) => handleError(toError(error))), + onOpenActor: () => { + if (invite.invitedByUsername) { + openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`) + } + }, + }, + }) + return true + } + + async function requireAccount() { + if (!auth.isReady?.value) { + await new Promise((resolve) => { + const stop = watch(auth.isReady!, (ready) => { + if (ready) { + stop() + resolve() + } + }) + }) + } + if (auth.session_token.value) return true + return (await accountRequiredModal.value?.show()) ?? false + } + + async function installFromInviteId(inviteId: string) { + try { + if (!(await requireAccount())) return + const invite = await install_accept_shared_instance_invite(inviteId) + await showInstallOrAlreadyInstalled(invite.sharedInstanceId, invite.preview, async () => { + await install_shared_instance( + invite.sharedInstanceId, + invite.preview.name, + invite.managerId, + invite.serverManagerName, + invite.serverManagerIconUrl, + invite.instanceIconUrl, + ) + await queryClient.invalidateQueries({ queryKey: ['instances'] }) + }) + } catch (error) { + handleError(toError(error)) + } + } + + return { + handleNotification, + installFromInviteId, + handleAlreadyInstalledCancel, + handleAlreadyInstalledGoToInstance, + handleAlreadyInstalledInstallAnyway, + } +} diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue index f76801f9c..d2c2388ae 100644 --- a/apps/app-frontend/src/components/ui/world/InstanceItem.vue +++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue @@ -80,6 +80,7 @@ const playing = ref(false) const play = async (event: MouseEvent) => { event?.stopPropagation() + if (props.instance.quarantined) return loading.value = true await run(props.instance.id) .catch((err) => handleSevereError(err, { instanceId: props.instance.id })) @@ -192,8 +193,14 @@ onUnmounted(() => {

+
@@ -107,10 +131,11 @@ import { StopCircleIcon, TerminalSquareIcon, UpdatedIcon, + UserPlusIcon, XIcon, } from '@modrinth/assets' -import { injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui' -import { useQueryClient } from '@tanstack/vue-query' +import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui' +import { useQuery, useQueryClient } from '@tanstack/vue-query' import { convertFileSrc } from '@tauri-apps/api/core' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' @@ -119,9 +144,13 @@ import { useRoute, useRouter } from 'vue-router' import ContextMenu from '@/components/ui/ContextMenu.vue' import ExportModal from '@/components/ui/ExportModal.vue' +import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue' import InstancePageHeader from '@/components/ui/instance-page-header/index.vue' +import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' +import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue' +import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue' import { fetchCachedServerStatus, getFreshCachedServerStatus, @@ -130,10 +159,25 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole' import { trackEvent } from '@/helpers/analytics' import { get_project_v3 } from '@/helpers/cache.js' import { instance_listener, process_listener } from '@/helpers/events' -import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install' -import { get, get_full_path, kill, run } from '@/helpers/instance' +import { + getSharedInstanceUnavailableReason, + install_existing_instance, + install_get_shared_instance_preview, + install_pack_to_existing_instance, + isSharedInstanceUnavailableError, + type SharedInstanceUnavailableReason, +} from '@/helpers/install' +import { + can_current_user_use_shared_instances, + get, + get_full_path, + kill, + remove, + run, +} from '@/helpers/instance' import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content' import { get_by_instance_id } from '@/helpers/process' +import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors' import type { GameInstance } from '@/helpers/types' import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js' import { refreshWorlds, type ServerStatus } from '@/helpers/worlds' @@ -141,10 +185,13 @@ import { injectServerInstall } from '@/providers/server-install' import { handleSevereError } from '@/store/error.js' import { useBreadcrumbs, useTheming } from '@/store/state' +import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state' + dayjs.extend(relativeTime) const { addNotification, handleError } = injectNotificationManager() const { playServerProject } = injectServerInstall() +const auth = injectAuth() const queryClient = useQueryClient() const route = useRoute() @@ -167,17 +214,23 @@ const instance = ref() const preloadedContent = ref(null) const playing = ref(false) const loading = ref(false) +const checkingSharedInstanceLaunch = ref(false) const subpagePending = ref(false) const stopping = ref(false) const exportModal = ref>() const updateToPlayModal = ref>() +const sharedInstanceUpdateModal = ref>() +const sharedInstanceReportModal = ref>() +const deleteConfirmModal = ref>() +const selectedInstanceToDelete = ref(null) + +const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors() useLoadingBarToken(subpagePending) const isServerInstance = ref(false) const linkedProjectV3 = ref() const selected = ref([]) - const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server) const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data) const liveServerStatusOnline = ref(false) @@ -189,11 +242,27 @@ const playersOnline = ref(undefined) const ping = ref(undefined) const loadingServerPing = ref(false) const activeInstanceId = ref() +const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError) +provideSharedInstanceState(sharedInstanceState) +const { + actionsLocked: sharedInstanceActionsLocked, + expectedUserId: sharedInstanceExpectedUserId, + manager: sharedInstanceManager, + refreshUpdatePreview: refreshSharedInstanceUpdatePreview, + setUnavailable: setSharedInstanceUnavailable, + signedOut: sharedInstanceSignedOut, + unavailableManager: sharedInstanceUnavailableManager, + unavailableReason: sharedInstanceUnavailableReason, + wrongAccount: sharedInstanceWrongAccount, +} = sharedInstanceState watch( () => router.currentRoute.value, (nextRoute) => { - if (nextRoute.path.startsWith('/instance')) { + if ( + nextRoute.path.startsWith('/instance') && + (!instance.value || nextRoute.params.id === instance.value.id) + ) { displayedInstanceRoute.value = nextRoute } }, @@ -219,17 +288,15 @@ function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) { } async function fetchInstance() { - isServerInstance.value = false - linkedProjectV3.value = undefined - preloadedContent.value = null - resetServerStatus() + const requestedInstanceId = route.params.id as string + const requestedRouteName = route.name - const nextInstance = await get(route.params.id as string).catch(handleError) + const nextInstance = await get(requestedInstanceId).catch(handleError) let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined let nextIsServerInstance = false const contentPreloadPromise = - nextInstance && isContentSubpageRoute() + nextInstance && isContentSubpageRoute(requestedRouteName) ? loadInstanceContentData(nextInstance.id, undefined, handleError) : Promise.resolve(null) @@ -245,13 +312,25 @@ async function fetchInstance() { } } - const nextPreloadedContent = await contentPreloadPromise + let nextPreloadedContent = await contentPreloadPromise + let nextRoute = router.currentRoute.value + if (nextRoute.params.id !== requestedInstanceId) return + + if (nextInstance && isContentSubpageRoute(nextRoute.name) && !nextPreloadedContent) { + nextPreloadedContent = await loadInstanceContentData(nextInstance.id, undefined, handleError) + nextRoute = router.currentRoute.value + if (nextRoute.params.id !== requestedInstanceId) return + } instance.value = nextInstance ?? undefined + displayedInstanceRoute.value = nextRoute + sharedInstanceState.reset() + sharedInstanceState.refreshAvailability() linkedProjectV3.value = nextLinkedProjectV3 isServerInstance.value = nextIsServerInstance preloadedContent.value = nextPreloadedContent activeInstanceId.value = nextInstance?.id + resetServerStatus() fetchDeferredData(nextInstance?.id) @@ -335,29 +414,78 @@ const isFixedRender = computed(() => renderMode.value === 'fixed') const contentSubpageProps = computed(() => isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {}, ) +const { data: canCurrentUserUseSharedInstances } = useQuery({ + queryKey: computed(() => ['shared-instance-eligibility', auth.user.value?.id]), + queryFn: can_current_user_use_shared_instances, + enabled: () => !!auth.session_token.value && !!auth.user.value?.id, + retry: false, + staleTime: Infinity, + refetchOnMount: 'always', + refetchOnWindowFocus: false, + refetchOnReconnect: false, +}) +const currentUserCanUseSharedInstances = computed( + () => !auth.session_token.value || canCurrentUserUseSharedInstances.value !== false, +) +const showShareTab = computed(() => { + const linkType = instance.value?.link?.type -const tabs = computed(() => [ - { - label: 'Content', - href: `${basePath.value}`, - icon: BoxesIcon, + return ( + currentUserCanUseSharedInstances.value && + !instance.value?.quarantined && + instance.value?.shared_instance?.role !== 'member' && + linkType !== 'server_project' && + linkType !== 'server_project_modpack' + ) +}) + +const tabs = computed(() => { + const instanceTabs = [ + { + label: 'Content', + href: `${basePath.value}`, + icon: BoxesIcon, + }, + { + label: 'Files', + href: `${basePath.value}/files`, + icon: FolderOpenIcon, + }, + { + label: 'Worlds', + href: `${basePath.value}/worlds`, + icon: GlobeIcon, + }, + { + label: 'Logs', + href: `${basePath.value}/logs`, + icon: TerminalSquareIcon, + }, + ] + + if (showShareTab.value) { + instanceTabs.push({ + label: 'Share', + href: `${basePath.value}/share`, + icon: UserPlusIcon, + }) + } + + return instanceTabs +}) + +watch( + () => ({ + quarantined: instance.value?.quarantined ?? false, + routeName: router.currentRoute.value.name, + }), + ({ quarantined, routeName }) => { + if (quarantined && routeName === 'InstanceShare') { + void router.replace(basePath.value) + } }, - { - label: 'Files', - href: `${basePath.value}/files`, - icon: FolderOpenIcon, - }, - { - label: 'Worlds', - href: `${basePath.value}/worlds`, - icon: GlobeIcon, - }, - { - label: 'Logs', - href: `${basePath.value}/logs`, - icon: TerminalSquareIcon, - }, -]) + { immediate: true }, +) if (instance.value) { breadcrumbs.setName( @@ -375,13 +503,8 @@ if (instance.value) { const options = ref | null>(null) -const startInstance = async (context: string) => { - if (!instance.value) return - if (updateToPlayModal.value?.hasUpdate) { - updateToPlayModal.value.show(instance.value) - return - } - +const launchInstance = async (context: string) => { + if (!instance.value || instance.value.quarantined) return loading.value = true try { await run(route.params.id as string) @@ -391,6 +514,7 @@ const startInstance = async (context: string) => { } loading.value = false + if (!instance.value) return trackEvent('InstanceStart', { loader: instance.value.loader, game_version: instance.value.game_version, @@ -398,6 +522,65 @@ const startInstance = async (context: string) => { }) } +async function handleSharedInstanceUnavailable( + reason: SharedInstanceUnavailableReason | null = null, +) { + notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value) + await fetchInstance() + setSharedInstanceUnavailable(reason) +} + +const startInstance = async (context: string) => { + if (!instance.value || instance.value.quarantined) return + if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return + + const instanceId = instance.value.id + const isSharedInstanceMember = instance.value.shared_instance?.role === 'member' + const canCheckSharedInstanceUpdate = + !!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value + + if (canCheckSharedInstanceUpdate) { + let preview: Awaited> + checkingSharedInstanceLaunch.value = true + try { + preview = await refreshSharedInstanceUpdatePreview() + } catch (error) { + if (isSharedInstanceUnavailableError(error)) { + await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error)) + } else { + notifySharedInstanceError(error) + } + return + } finally { + checkingSharedInstanceLaunch.value = false + } + + if (instance.value?.id !== instanceId) return + + if (preview?.updateAvailable && sharedInstanceUpdateModal.value) { + sharedInstanceUpdateModal.value.show(instance.value, preview, async () => { + await fetchInstance() + await launchInstance(context) + }) + return + } + } + + if (updateToPlayModal.value?.hasUpdate) { + if (isSharedInstanceMember) { + updateToPlayModal.value.show(instance.value, null, async () => { + await fetchInstance() + await launchInstance(context) + }) + } else { + updateToPlayModal.value.show(instance.value) + } + return + } + + await launchInstance(context) +} + const stopInstance = async (context: string) => { stopping.value = true await kill(route.params.id as string).catch(handleError) @@ -413,7 +596,7 @@ const stopInstance = async (context: string) => { } const handlePlayServer = async () => { - if (!instance.value?.link?.project_id) return + if (!instance.value?.link?.project_id || instance.value.quarantined) return loading.value = true try { await playServerProject(instance.value.link.project_id) @@ -424,6 +607,7 @@ const handlePlayServer = async () => { } const repairInstance = async () => { + if (instance.value.quarantined) return if ( instance.value.install_stage !== 'pack_installed' && (instance.value.link?.type === 'modrinth_modpack' || @@ -441,7 +625,7 @@ const repairInstance = async () => { } const createShortcut = async () => { - if (!instance.value) return + if (!instance.value || instance.value.quarantined) return try { const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id) if (!shortcutPath) return @@ -459,10 +643,51 @@ const createShortcut = async () => { } } +async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false) { + const reportInstance = instance.value + const sharedInstance = reportInstance?.shared_instance + if (!reportInstance || sharedInstance?.role !== 'member') return + + try { + const preview = await install_get_shared_instance_preview( + sharedInstance.id, + reportInstance.name, + ) + if (instance.value?.id !== reportInstance.id) return + if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide() + sharedInstanceReportModal.value?.showReport(preview, event) + } catch (error) { + handleError(error as Error) + } +} + +function handleSharedInstanceReported(deleteInstance: boolean) { + if (!deleteInstance || !instance.value) return + requestInstanceDeletion() +} + +function requestInstanceDeletion() { + if (!instance.value) return + selectedInstanceToDelete.value = instance.value + deleteConfirmModal.value?.show() +} + +async function deleteSelectedInstance() { + const selectedInstance = selectedInstanceToDelete.value + selectedInstanceToDelete.value = null + if (!selectedInstance) return + + trackEvent('InstanceRemove', { + loader: selectedInstance.loader, + game_version: selectedInstance.game_version, + }) + await router.push({ path: '/' }) + await remove(selectedInstance.id).catch(handleError) +} + const handleRightClick = (event: MouseEvent) => { const baseOptions = [ - { name: 'add_content' }, - { type: 'divider' }, + ...(instance.value?.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]), { name: 'edit' }, { name: 'open_folder' }, { name: 'copy_path' }, @@ -480,10 +705,14 @@ const handleRightClick = (event: MouseEvent) => { ...baseOptions, ] : [ - { - name: 'play', - color: 'primary', - }, + ...(instance.value?.quarantined + ? [] + : [ + { + name: 'play', + color: 'primary', + }, + ]), ...baseOptions, ], ) diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index 93db7a5ee..1da28f349 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -19,14 +19,26 @@ ref="modpackContentModal" :modpack-name="displayedModpackProject?.title" :modpack-icon-url="displayedModpackProject?.icon_url ?? undefined" - :enable-toggle="!props.isServerInstance" + :enable-toggle="!props.isServerInstance && !isSharedMember && !isQuarantined" :busy="isBulkOperating" :get-overflow-options="getOverflowOptions" - :switch-version="handleSwitchVersion" + :switch-version=" + props.isServerInstance || isSharedMember || isQuarantined + ? undefined + : handleSwitchVersion + " @update:enabled="handleModpackContentToggle" @bulk:enable="(items) => handleModpackContentBulkToggle(items, true)" @bulk:disable="(items) => handleModpackContentBulkToggle(items, false)" /> + - + void preloadedContent?: InstanceContentData | null }>() +const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance)) +const { + isManagedModpack: isSharedMember, + isQuarantined, + canMutateContent, + canUpdateContent: canUpdateProject, +} = managedContentPolicy function hasPreloadedContent(contentData: InstanceContentData | null | undefined) { return contentData?.path === props.instance.id @@ -287,6 +314,7 @@ const isBulkOperating = ref(false) const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed') const isPackLocked = computed( () => + props.instance.quarantined || props.instance?.link?.type === 'modrinth_modpack' || props.instance?.link?.type === 'server_project_modpack', ) @@ -296,6 +324,8 @@ const exportModal = ref(null) const contentUpdaterModal = ref | null>() const modpackContentModal = ref | null>() const modpackUpdateConfirmModal = ref | null>() +const sharedDisableConfirmModal = ref | null>() +const pendingModpackDisableItems = ref([]) const unknownFileWarningModal = ref | null>() const unknownFileName = ref('') let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null @@ -377,8 +407,8 @@ function hasContentOperation(item: ContentItem) { return keys.some((key) => activeContentOperationKeys.value.has(key)) } -function canUpdateProject(item: ContentItem) { - return !!item.file_path && !!item.has_update && !!item.update_version_id +function canDeleteContent(item: ContentItem) { + return canMutateContent(item) } function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) { @@ -486,7 +516,7 @@ async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: st } async function handleBrowseContent() { - if (!props.instance) return + if (!props.instance || props.instance.quarantined) return await router.push({ path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`, query: { i: props.instance.id }, @@ -494,7 +524,7 @@ async function handleBrowseContent() { } async function handleUploadFiles() { - if (!props.instance) return + if (!props.instance || props.instance.quarantined) return const files = await open({ multiple: true }) if (!files) return const selectedFiles: Array<{ path: string; filename: string }> = [] @@ -517,26 +547,37 @@ async function handleUploadFiles() { }), ) - const addedFiles: string[] = [] + const confirmedFiles: Array<{ path: string; filename: string }> = [] for (const [index, { path, filename }] of selectedFiles.entries()) { if (!fileRecognition[index] && !(await confirmUnknownFileInstallation(filename))) { continue } - try { - await add_project_from_path(props.instance.id, path) - addedFiles.push(filename) - } catch (e) { - handleError(e as Error) - } + confirmedFiles.push({ path, filename }) } - await initProjects() - if (addedFiles.length > 0) { - const names = addedFiles.map((f) => { - const item = projects.value.find( - (p) => p.file_name === f || p.file_name === f.replace('.zip', '.jar'), - ) - return item?.project?.title ?? f + const addedFiles = ( + await Promise.all( + confirmedFiles.map(async ({ path, filename }) => { + try { + const installedPath = await add_project_from_path(props.instance.id, path) + return { filename, installedPath } + } catch (error) { + handleError(error as Error) + return null + } + }), + ) + ).filter((result): result is { filename: string; installedPath: string } => result !== null) + const uniqueAddedFiles = [ + ...new Map(addedFiles.map((file) => [file.installedPath, file])).values(), + ] + + await initProjects('must_revalidate') + + if (uniqueAddedFiles.length > 0) { + const names = uniqueAddedFiles.map(({ filename, installedPath }) => { + const item = projects.value.find((project) => project.file_path === installedPath) + return item?.project?.title ?? filename }) addNotification({ type: 'success', @@ -784,6 +825,7 @@ async function updateProject(mod: ContentItem) { } async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) { + if (!canMutateContent(mod)) return if (!mod.file_path) return const operation = beginContentOperation(mod) if (!operation) return @@ -920,6 +962,7 @@ async function handleUpdate(id: string) { } async function handleSwitchVersion(item: ContentItem) { + if (!canMutateContent(item)) return if (!item.project?.id || !item.version?.id) return const requestId = beginUpdateRequest() @@ -947,10 +990,32 @@ async function handleSwitchVersion(item: ContentItem) { } async function handleModpackContentToggle(item: ContentItem, enabled: boolean) { + if (!enabled && managedContentPolicy.disableWarning([item])) { + pendingModpackDisableItems.value = [item] + sharedDisableConfirmModal.value?.show() + return + } + await toggleDisableDebounced(item, enabled) } async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) { + if (!enabled && managedContentPolicy.disableWarning(items)) { + pendingModpackDisableItems.value = items + sharedDisableConfirmModal.value?.show() + return + } + + await setModpackContentEnabled(items, enabled) +} + +async function confirmPendingModpackContentDisable() { + const items = [...pendingModpackDisableItems.value] + pendingModpackDisableItems.value = [] + await setModpackContentEnabled(items, false) +} + +async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) { await Promise.all(items.map((item) => toggleDisableMod(item, enabled))) } @@ -1300,29 +1365,7 @@ function applyContentData(contentData: InstanceContentData) { return true } -provideAppBackup({ - async createBackup() { - const allInstances = await list() - const prefix = `${props.instance.name} - Backup #` - const existingNums = allInstances - .filter((p) => p.name.startsWith(prefix)) - .map((p) => parseInt(p.name.slice(prefix.length), 10)) - .filter((n) => !isNaN(n)) - const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1 - const job = await install_duplicate_instance(props.instance.id) - const newInstanceId = installJobInstanceId(job) - if (newInstanceId) { - await edit(newInstanceId, { name: `${prefix}${nextNum}` }) - } - }, -}) - -const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed' -const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null) -function dismissContentHint() { - showContentHint.value = false - localStorage.setItem(CONTENT_HINT_KEY, 'true') -} +provideInstanceBackup(() => props.instance) provideContentManager({ items: mergedProjects, @@ -1378,21 +1421,31 @@ provideContentManager({ }), isPackLocked, isBusy: isInstanceBusy, + disableAddContent: isQuarantined, + disableAddContentTooltip: formatMessage(messages.lockedContent), isBulkOperating, skipNonEssentialWarnings, contentTypeLabel: ref(formatMessage(messages.contentTypeProject)), toggleEnabled: toggleDisableDebounced, bulkEnableItems: (items: ContentItem[]) => Promise.all( - items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)), + items + .filter((item) => canMutateContent(item) && !item.enabled) + .map((item) => toggleDisableMod(item, true)), ).then(() => {}), bulkDisableItems: (items: ContentItem[]) => Promise.all( - items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)), + items + .filter((item) => canMutateContent(item) && item.enabled) + .map((item) => toggleDisableMod(item, false)), ).then(() => {}), deleteItem: removeMod, bulkDeleteItems: (items: ContentItem[]) => - Promise.all(items.map((item) => removeMod(item))).then(() => {}), + Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}), + canDeleteItem: canDeleteContent, + canToggleItem: canMutateContent, + getDeleteWarning: managedContentPolicy.deleteWarning, + getDisableWarning: managedContentPolicy.disableWarning, getDeleteDependencyWarning, refresh: () => initProjects('must_revalidate'), browse: handleBrowseContent, @@ -1401,14 +1454,15 @@ provideContentManager({ updateItem: handleUpdate, bulkUpdateAll: bulkUpdateAllProjects, bulkUpdateItem: updateProject, - updateModpack: props.isServerInstance ? undefined : handleModpackUpdate, + updateModpack: + props.isServerInstance || isSharedMember.value || isQuarantined.value + ? undefined + : handleModpackUpdate, viewModpackContent: handleModpackContent, unlinkModpack: unpairInstance, openSettings: props.openSettings, switchVersion: handleSwitchVersion, getOverflowOptions, - showContentHint, - dismissContentHint, shareItems: handleShareItems, getItemId: getContentItemId, mapToTableItem: (item: ContentItem) => ({ @@ -1440,8 +1494,11 @@ provideContentManager({ link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`), } : undefined, - enabled: item.enabled, + enabled: canMutateContent(item) ? item.enabled : undefined, installing: item.installing, + hideDelete: !canDeleteContent(item), + hideSwitchVersion: !canMutateContent(item), + hasUpdate: canUpdateProject(item), }), filterPersistKey: props.instance.id, }) diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue index 6d6be96fc..1ddcb5e94 100644 --- a/apps/app-frontend/src/pages/instance/Worlds.vue +++ b/apps/app-frontend/src/pages/instance/Worlds.vue @@ -88,6 +88,7 @@ :highlighted="highlightedWorld === getWorldIdentifier(world)" :supports-server-quick-play="supportsServerQuickPlay" :supports-world-quick-play="supportsWorldQuickPlay" + :quarantined="instance.quarantined" :current-protocol="protocolVersion" :playing-instance="playing" :playing-world="worldsMatch(world, worldPlaying)" @@ -273,6 +274,7 @@ const instance = computed(() => props.instance) const playing = computed(() => props.playing) function play(world: World) { + if (props.instance.quarantined) return emit('play', world) } @@ -523,6 +525,7 @@ function handleJoinError(err: Error) { } async function joinWorld(world: World) { + if (instance.value.quarantined) return console.log(`Joining world ${getWorldIdentifier(world)}`) startingInstance.value = true worldPlaying.value = world diff --git a/apps/app-frontend/src/pages/instance/index.js b/apps/app-frontend/src/pages/instance/index.js index b96705e88..0e6a0e7ec 100644 --- a/apps/app-frontend/src/pages/instance/index.js +++ b/apps/app-frontend/src/pages/instance/index.js @@ -3,6 +3,7 @@ import Index from './Index.vue' import Logs from './Logs.vue' import Mods from './Mods.vue' import Overview from './Overview.vue' +import Share from './share/index.vue' import Worlds from './Worlds.vue' -export { Files, Index, Logs, Mods, Overview, Worlds } +export { Files, Index, Logs, Mods, Overview, Share, Worlds } diff --git a/apps/app-frontend/src/pages/instance/share/index.vue b/apps/app-frontend/src/pages/instance/share/index.vue new file mode 100644 index 000000000..2588ba79f --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/index.vue @@ -0,0 +1,373 @@ + + + diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue new file mode 100644 index 000000000..223c915c0 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue @@ -0,0 +1,304 @@ + + + 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 new file mode 100644 index 000000000..1f7983dcd --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue @@ -0,0 +1,122 @@ + + + diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue new file mode 100644 index 000000000..22839fe4d --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue @@ -0,0 +1,15 @@ + + + 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 new file mode 100644 index 000000000..a6e478ba4 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts @@ -0,0 +1,20 @@ +export type ShareMethod = 'direct' | 'link' +export type MethodFilter = ShareMethod | 'all' +export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions' + +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/share/use-shared-instance-invite-candidates.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-candidates.ts new file mode 100644 index 000000000..b25e9e91d --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-candidates.ts @@ -0,0 +1,105 @@ +import { + injectNotificationManager, + type InvitePlayersSearchUser, + type InvitePlayersUser, +} from '@modrinth/ui' +import { computed, type Ref } from 'vue' + +import { useFriends } from '@/composables/use-friends' +import { getFriendUserId } from '@/helpers/friends.ts' +import { get as getCredentials } from '@/helpers/mr_auth.ts' +import { search_user } from '@/helpers/users.ts' + +import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types' + +export function useSharedInstanceInviteCandidates(options: { + rows: Ref + currentUserId: Ref + isSignedIn: Ref + actionsLocked: Ref +}) { + const { handleError } = injectNotificationManager() + const friendsState = useFriends({ + currentUserId: options.currentUserId, + getCredentials, + enabled: computed( + () => + options.isSignedIn.value && !!options.currentUserId.value && !options.actionsLocked.value, + ), + onError: handleError, + }) + const friends = friendsState.friends + const invitedRows = computed(() => { + const invited = new Map() + for (const row of options.rows.value) { + invited.set(normalizeInviteKey(row.id), row) + invited.set(normalizeInviteKey(row.username), row) + } + return invited + }) + const inviteFriends = computed(() => + friends.value + .filter((friend) => friend.username && friend.accepted) + .sort((a, b) => Number(b.online) - Number(a.online)) + .map((friend) => { + const id = getFriendUserId(friend, options.currentUserId.value) + const invited = + invitedRows.value.get(normalizeInviteKey(id)) ?? + invitedRows.value.get(normalizeInviteKey(friend.username)) + return { + id, + username: friend.username, + avatarUrl: friend.avatar, + online: friend.online, + status: invited ? (invited.pending ? 'pending' : 'added') : 'available', + } + }), + ) + const candidateKeys = computed(() => { + const keys = new Set() + for (const friend of inviteFriends.value) { + keys.add(normalizeInviteKey(friend.id)) + keys.add(normalizeInviteKey(friend.username)) + } + return keys + }) + + async function search(query: string): Promise { + if (options.actionsLocked.value) return [] + const credentials = await getCredentials() + const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null + return (await search_user(query)) + .filter((user) => user.id !== ownUserId) + .filter((user) => { + const id = normalizeInviteKey(user.id) + const username = normalizeInviteKey(user.username) + return ( + !candidateKeys.value.has(id) && + !candidateKeys.value.has(username) && + !invitedRows.value.has(id) && + !invitedRows.value.has(username) + ) + }) + .map((user) => ({ + id: user.id, + username: user.username, + avatarUrl: user.avatar_url || undefined, + })) + } + + async function requestFriend(user: InvitePlayersUser) { + if (options.actionsLocked.value) return + const credentials = await getCredentials() + const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null + if (ownUserId && normalizeInviteKey(user.id) === normalizeInviteKey(ownUserId)) return + if (!friendsState.findFriend(user.id, user.username)) { + friendsState.requestFriend({ + id: user.id, + username: user.username, + avatarUrl: user.avatarUrl, + }) + } + } + + return { inviteFriends, search, requestFriend } +} diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts new file mode 100644 index 000000000..7a7d0a77a --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts @@ -0,0 +1,63 @@ +import type { InviteLinkSettings } from '@modrinth/ui' +import { computed, type Ref, ref, watch } from 'vue' + +import { config } from '@/config' +import { toError } from '@/helpers/errors' +import { create_shared_instance_invite_link } from '@/helpers/instance' + +export function useSharedInstanceInviteLink( + instanceId: Ref, + onError: (error: unknown) => void, +) { + const details = ref>>() + const pending = ref(false) + const link = computed(() => + details.value + ? `${config.siteUrl}/share/${encodeURIComponent(details.value.inviteId)}` + : undefined, + ) + + async function ensure() { + if (details.value) return true + if (pending.value) return false + + pending.value = true + try { + details.value = await create_shared_instance_invite_link(instanceId.value) + return true + } catch (error) { + onError(error) + return false + } finally { + pending.value = false + } + } + + async function update(settings: InviteLinkSettings) { + if (!details.value) return + + pending.value = true + try { + const maxAgeSeconds = Math.max( + 1, + Math.min(604800, Math.floor((settings.expiresAt.getTime() - Date.now()) / 1000)), + ) + details.value = await create_shared_instance_invite_link(instanceId.value, { + maxAgeSeconds, + maxUses: settings.maxUses, + replaceInviteId: details.value.inviteId, + }) + } catch (error) { + throw toError(error) + } finally { + pending.value = false + } + } + + watch(instanceId, () => { + details.value = undefined + pending.value = false + }) + + return { details, pending, link, ensure, update } +} diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts new file mode 100644 index 000000000..48bca947e --- /dev/null +++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts @@ -0,0 +1,241 @@ +import type { InvitePlayersUser } from '@modrinth/ui' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, type Ref } from 'vue' + +import { get_user_many } from '@/helpers/cache.js' +import { + get_shared_instance_users, + invite_shared_instance_users, + remove_shared_instance_users, + type SharedInstanceUser, + type SharedInstanceUsers, +} from '@/helpers/instance' +import type { GameInstance } from '@/helpers/types' + +import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types' + +type MembersQueryKey = readonly ['sharedInstanceUsers', string] + +type OptimisticChange = { + queryKey: MembersQueryKey + userId: string + previousRow?: ShareRow + previousIndex: number +} + +type InviteVariables = { + user: InvitePlayersUser + change: OptimisticChange +} + +type RemoveVariables = { + id: string + hasPendingRecipients: boolean + change: OptimisticChange +} + +export function useSharedInstanceMembers(options: { + instance: Ref + currentUserId: Ref + isSignedIn: Ref + actionsLocked: Ref + onError: (error: unknown) => void +}) { + const queryClient = useQueryClient() + const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const) + const invitingUserIds = new Set() + const removingUserIds = new Set() + + const query = useQuery({ + queryKey, + queryFn: ({ queryKey }) => fetchRows(queryKey), + enabled: () => + options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value, + staleTime: Infinity, + refetchOnMount: 'always', + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }) + const rows = computed(() => query.data.value ?? []) + + const inviteMutation = useMutation({ + mutationFn: ({ user, change }: InviteVariables) => + invite_shared_instance_users(change.queryKey[1], [user.id]), + onError: (error, { change }) => { + rollback(change) + options.onError(error) + }, + onSettled: (_data, _error, { user }) => { + invitingUserIds.delete(normalizeInviteKey(user.id)) + }, + }) + + const removeMutation = useMutation({ + mutationFn: ({ id, hasPendingRecipients, change }: RemoveVariables) => + remove_shared_instance_users(change.queryKey[1], [id], hasPendingRecipients), + onError: (error, { change }) => { + rollback(change) + options.onError(error) + }, + onSettled: (_data, _error, { id }) => { + removingUserIds.delete(normalizeInviteKey(id)) + }, + }) + + async function fetchRows(activeQueryKey: MembersQueryKey) { + const users = await get_shared_instance_users(activeQueryKey[1]) + const loadedRows = await usersToRows(users) + const currentRows = queryClient.getQueryData(activeQueryKey) ?? [] + return preserveRowOrder(loadedRows, currentRows) + } + + async function usersToRows(users: SharedInstanceUsers): Promise { + const excludedIds = new Set( + [options.instance.value.shared_instance?.manager_id, options.currentUserId.value].filter( + (id): id is string => !!id, + ), + ) + const usersToDisplay = userEntries(users).filter((user) => !excludedIds.has(user.id)) + if (usersToDisplay.length === 0) return [] + + const profiles = (await get_user_many(usersToDisplay.map((user) => user.id))) as Array<{ + id: string + username?: string + avatar_url?: string | null + }> + + return usersToDisplay.map((user) => { + const profile = profiles.find((candidate) => candidate.id === user.id) + const joinedAt = parseDate(user.joined_at) + return { + id: user.id, + username: profile?.username ?? user.id, + avatarUrl: profile?.avatar_url ?? undefined, + lastPlayedAt: parseDate(user.last_played), + joinedAt, + method: user.join_type === 'link' ? 'link' : 'direct', + pending: !joinedAt, + } + }) + } + + function find(id: string, username: string) { + const normalizedId = normalizeInviteKey(id) + const normalizedUsername = normalizeInviteKey(username) + return rows.value.find( + (row) => + normalizeInviteKey(row.id) === normalizedId || + normalizeInviteKey(row.username) === normalizedUsername, + ) + } + + function invite(user: InvitePlayersUser) { + const normalizedId = normalizeInviteKey(user.id) + if ( + options.actionsLocked.value || + invitingUserIds.has(normalizedId) || + find(user.id, user.username) + ) { + return + } + + invitingUserIds.add(normalizedId) + const change = beginOptimisticChange(user.id) + updateRows(change.queryKey, (currentRows) => [...currentRows, inviteUserToRow(user)]) + inviteMutation.mutate({ user, change }) + } + + function remove(id: string) { + const normalizedId = normalizeInviteKey(id) + if (options.actionsLocked.value || removingUserIds.has(normalizedId)) return + + removingUserIds.add(normalizedId) + const hasPendingRecipients = rows.value.some( + (row) => row.pending && normalizeInviteKey(row.id) !== normalizedId, + ) + const change = beginOptimisticChange(id) + updateRows(change.queryKey, (currentRows) => + currentRows.filter((row) => normalizeInviteKey(row.id) !== normalizedId), + ) + removeMutation.mutate({ id, hasPendingRecipients, change }) + } + + function beginOptimisticChange(userId: string): OptimisticChange { + const activeQueryKey = queryKey.value + void queryClient.cancelQueries({ queryKey: activeQueryKey, exact: true }, { revert: false }) + + const currentRows = queryClient.getQueryData(activeQueryKey) ?? [] + const previousIndex = currentRows.findIndex( + (row) => normalizeInviteKey(row.id) === normalizeInviteKey(userId), + ) + return { + queryKey: activeQueryKey, + userId, + previousRow: previousIndex === -1 ? undefined : currentRows[previousIndex], + previousIndex, + } + } + + function rollback(change: OptimisticChange) { + const normalizedId = normalizeInviteKey(change.userId) + updateRows(change.queryKey, (currentRows) => { + const rowsWithoutUser = currentRows.filter( + (row) => normalizeInviteKey(row.id) !== normalizedId, + ) + if (!change.previousRow) return rowsWithoutUser + + const previousIndex = Math.min(change.previousIndex, rowsWithoutUser.length) + return [ + ...rowsWithoutUser.slice(0, previousIndex), + change.previousRow, + ...rowsWithoutUser.slice(previousIndex), + ] + }) + } + + function updateRows(activeQueryKey: MembersQueryKey, update: (rows: ShareRow[]) => ShareRow[]) { + queryClient.setQueryData(activeQueryKey, (currentRows = []) => update(currentRows)) + } + + return { rows, query, find, invite, remove } +} + +function userEntries(users: SharedInstanceUsers): SharedInstanceUser[] { + if (users.users?.length > 0) return users.users + return users.user_ids.map((id) => ({ + id, + joined_at: null, + join_type: 'invite', + last_played: null, + })) +} + +function parseDate(value?: string | null) { + if (!value) return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function inviteUserToRow(user: InvitePlayersUser): ShareRow { + return { + id: user.id, + username: user.username, + avatarUrl: user.avatarUrl ?? undefined, + lastPlayedAt: null, + joinedAt: null, + method: 'direct', + pending: true, + } +} + +function preserveRowOrder(rows: ShareRow[], previousRows: ShareRow[]) { + const rowsById = new Map(rows.map((row) => [normalizeInviteKey(row.id), row])) + const orderedRows = previousRows.flatMap((previousRow) => { + const id = normalizeInviteKey(previousRow.id) + const row = rowsById.get(id) + if (!row) return [] + rowsById.delete(id) + return [row] + }) + return [...orderedRows, ...rowsById.values()] +} diff --git a/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts b/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts new file mode 100644 index 000000000..408bd3c2c --- /dev/null +++ b/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts @@ -0,0 +1,228 @@ +import { injectAuth } from '@modrinth/ui' +import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue' + +import { useUserQuery } from '@/composables/users/use-user-query' +import { + getSharedInstanceUnavailableReason, + install_get_shared_instance_update_preview, + isSharedInstanceUnavailableError, + type SharedInstanceUnavailableReason, +} from '@/helpers/install' +import type { GameInstance } from '@/helpers/types' + +export type SharedInstanceManager = + | { + type: 'user' + name: string + avatarUrl?: string + tintBy: string + } + | { + type: 'server' + name: string + avatarUrl?: string + tintBy: string + } + +export function useSharedInstanceState( + instance: Ref, + offline: Ref, + notifyError: (error: unknown) => void, +) { + const auth = injectAuth() + const updatePreview = + ref>>(null) + const updatePreviewLoaded = ref(false) + const unavailableReason = ref(null) + const availabilityCheckKey = ref(null) + const availabilityRefresh = ref(0) + let availabilityRequestId = 0 + let availabilityRequest: { + key: string + promise: Promise<{ + preview: Awaited> + error: unknown | null + }> + } | null = null + + const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null) + const wrongAccount = computed(() => { + if (auth.isReady && !auth.isReady.value) return false + if (!expectedUserId.value) return false + return auth.user.value?.id !== expectedUserId.value + }) + const actionsLocked = computed(() => wrongAccount.value) + const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null) + const signedOut = computed(() => !auth.session_token.value) + const managerUserId = computed(() => { + const attachment = instance.value?.shared_instance + if (!attachment) return null + if (attachment.role === 'owner') { + return actionsLocked.value ? (attachment.linked_user_id ?? null) : null + } + return attachment.manager_id ?? null + }) + const managerUserQuery = useUserQuery(managerUserId) + const manager = computed(() => { + const attachment = instance.value?.shared_instance + if (!attachment) return null + + if (attachment.server_manager_name) { + return { + type: 'server', + name: attachment.server_manager_name, + avatarUrl: attachment.server_manager_icon_url ?? undefined, + tintBy: attachment.server_manager_name, + } + } + + const user = managerUserQuery.data.value + if (!user) return null + return { + type: 'user', + name: user.username, + avatarUrl: user.avatar_url ?? undefined, + tintBy: user.id, + } + }) + const unavailableManager = computed(() => manager.value?.name ?? null) + + function reset() { + availabilityRequestId++ + availabilityRequest = null + availabilityCheckKey.value = null + updatePreview.value = null + updatePreviewLoaded.value = false + unavailableReason.value = null + } + + function refreshAvailability() { + availabilityCheckKey.value = null + updatePreviewLoaded.value = false + availabilityRefresh.value++ + } + + function setUnavailable(reason: SharedInstanceUnavailableReason | null) { + availabilityRequestId++ + availabilityRequest = null + availabilityCheckKey.value = null + updatePreview.value = null + updatePreviewLoaded.value = false + unavailableReason.value = reason + } + + async function checkAvailability(instanceId: string, key: string, throwError = false) { + const requestId = ++availabilityRequestId + let request = availabilityRequest + if (!request || request.key !== key) { + const promise = install_get_shared_instance_update_preview(instanceId).then( + (preview) => ({ preview, error: null }), + (error: unknown) => ({ preview: null, error }), + ) + request = { key, promise } + availabilityRequest = request + void promise.finally(() => { + if (availabilityRequest?.promise === promise) availabilityRequest = null + }) + } + + const result = await request.promise + if (!isCurrentRequest(requestId, instanceId, key)) return null + + if (result.error !== null) { + updatePreviewLoaded.value = false + if (isSharedInstanceUnavailableError(result.error)) { + updatePreview.value = null + unavailableReason.value = getSharedInstanceUnavailableReason(result.error) + } else if (!throwError) { + notifyError(result.error) + } + + if (throwError) throw result.error + return null + } + + updatePreview.value = result.preview + updatePreviewLoaded.value = true + unavailableReason.value = null + return result.preview + } + + async function refreshUpdatePreview() { + const instanceId = instance.value?.id + const userId = auth.user.value?.id + if (!instanceId || !userId) return null + + const key = `${instanceId}:${userId}` + availabilityCheckKey.value = key + return await checkAvailability(instanceId, key, true) + } + + function isCurrentRequest(requestId: number, instanceId: string, key: string) { + return ( + requestId === availabilityRequestId && + instance.value?.id === instanceId && + availabilityCheckKey.value === key + ) + } + + watch( + () => ({ + refresh: availabilityRefresh.value, + instanceId: instance.value?.id, + role: instance.value?.shared_instance?.role, + locked: actionsLocked.value, + offline: offline.value, + signedIn: !!auth.session_token.value, + userId: auth.user.value?.id ?? null, + authReady: auth.isReady?.value ?? true, + }), + async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => { + if (!instanceId || !role || locked || offline || !authReady || !signedIn || !userId) { + availabilityRequestId++ + availabilityRequest = null + availabilityCheckKey.value = null + updatePreview.value = null + updatePreviewLoaded.value = false + if (instanceId && role) unavailableReason.value = null + return + } + + const key = `${instanceId}:${userId}` + if (availabilityCheckKey.value === key) return + availabilityCheckKey.value = key + await checkAvailability(instanceId, key) + }, + { immediate: true }, + ) + + return { + actionsLocked, + shareActionsLocked, + unavailableReason, + unavailableManager, + manager, + updatePreview, + expectedUserId, + wrongAccount, + signedOut, + reset, + refreshAvailability, + refreshUpdatePreview, + setUnavailable, + } +} + +export type SharedInstanceState = ReturnType + +const sharedInstanceStateKey: InjectionKey = Symbol('shared-instance-state') + +export function provideSharedInstanceState(state: SharedInstanceState) { + provide(sharedInstanceStateKey, state) +} + +export function injectSharedInstanceState() { + const state = inject(sharedInstanceStateKey) + if (!state) throw new Error('Shared instance state has not been provided.') + return state +} diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 52c50fc96..e09914fcc 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -47,7 +47,7 @@
diff --git a/apps/app-frontend/src/providers/instance-backup.ts b/apps/app-frontend/src/providers/instance-backup.ts new file mode 100644 index 000000000..dee36ebf5 --- /dev/null +++ b/apps/app-frontend/src/providers/instance-backup.ts @@ -0,0 +1,25 @@ +import { provideAppBackup } from '@modrinth/ui' +import { type MaybeRefOrGetter, toValue } from 'vue' + +import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install' +import { edit, list } from '@/helpers/instance' +import type { GameInstance } from '@/helpers/types' + +export function provideInstanceBackup(instance: MaybeRefOrGetter) { + provideAppBackup({ + async createBackup() { + const source = toValue(instance) + const prefix = `${source.name} - Backup #` + const existingNumbers = (await list()) + .filter((candidate) => candidate.name.startsWith(prefix)) + .map((candidate) => Number.parseInt(candidate.name.slice(prefix.length), 10)) + .filter(Number.isFinite) + const nextNumber = existingNumbers.length ? Math.max(...existingNumbers) + 1 : 1 + const job = await install_duplicate_instance(source.id) + const backupInstanceId = installJobInstanceId(job) + if (backupInstanceId) { + await edit(backupInstanceId, { name: `${prefix}${nextNumber}` }) + } + }, + }) +} diff --git a/apps/app-frontend/src/providers/setup/auth.ts b/apps/app-frontend/src/providers/setup/auth.ts index 8832c09c6..7c688c6fa 100644 --- a/apps/app-frontend/src/providers/setup/auth.ts +++ b/apps/app-frontend/src/providers/setup/auth.ts @@ -1,5 +1,11 @@ import type { Labrinth } from '@modrinth/api-client' -import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui' +import { + type AuthFlow, + type AuthProvider, + type AuthRequestOptions, + type AuthUser, + provideAuth, +} from '@modrinth/ui' import { computed, type Ref, ref, watchEffect } from 'vue' type AppCredentials = { @@ -9,7 +15,11 @@ type AppCredentials = { export function setupAuthProvider( credentials: Ref, - requestSignIn: (redirectPath: string) => void | Promise, + requestSignIn: ( + redirectPath: string, + flow?: AuthFlow, + options?: AuthRequestOptions, + ) => void | Promise, ) { const sessionToken = ref(null) const user = ref(null) diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 591980575..2baa1edd0 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -215,6 +215,15 @@ export default new createRouter({ breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }], }, }, + { + path: 'share', + name: 'InstanceShare', + component: Instance.Share, + meta: { + useRootContext: true, + breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }], + }, + }, { path: '', name: 'Mods', diff --git a/apps/app-frontend/vite.config.ts b/apps/app-frontend/vite.config.ts index d97b76d8f..5b5d80d17 100644 --- a/apps/app-frontend/vite.config.ts +++ b/apps/app-frontend/vite.config.ts @@ -94,7 +94,7 @@ export default defineConfig({ }, // to make use of `TAURI_ENV_DEBUG` and other env variables // https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands - envPrefix: ['VITE_', 'TAURI_', 'MODRINTH_'], + envPrefix: ['VITE_', 'TAURI_', 'MODRINTH_', 'SHARED_INSTANCES_'], build: { rolldownOptions: { onwarn(warning, defaultHandler) { diff --git a/apps/app/build.rs b/apps/app/build.rs index 777e94473..56f05012a 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -55,7 +55,6 @@ fn main() { InlinedPlugin::new() .commands(&[ "get_importable_instances", - "import_instance", "is_valid_importable_instance", "get_default_launcher_path", ]) @@ -148,6 +147,11 @@ fn main() { "install_get_modpack_preview", "install_create_instance", "install_create_modpack_instance", + "install_get_shared_instance_preview", + "install_accept_shared_instance_invite", + "install_get_shared_instance_update_preview", + "install_shared_instance", + "install_update_shared_instance", "install_import_instance", "install_duplicate_instance", "install_existing_instance", @@ -176,6 +180,14 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "reports", + InlinedPlugin::new() + .commands(&["reports_create"]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "instance", InlinedPlugin::new() @@ -211,6 +223,15 @@ fn main() { "instance_kill", "instance_edit", "instance_edit_icon", + "instance_share_can_current_user_use", + "instance_share_get_users", + "instance_share_invite_users", + "instance_share_create_invite_link", + "instance_share_remove_users", + "instance_share_get_publish_preview", + "instance_share_publish", + "instance_share_unlink", + "instance_share_unpublish", "instance_export_mrpack", "instance_get_pack_export_candidates", ]) @@ -252,6 +273,14 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "users", + InlinedPlugin::new() + .commands(&["search_user"]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "utils", InlinedPlugin::new() @@ -276,7 +305,6 @@ fn main() { .commands(&[ "init_ads_window", "hide_ads_window", - "scroll_ads_window", "show_ads_window", "show_ads_consent_overlay", "show_ads_consent_preferences", diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index 2cb252176..33dcb58f5 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -102,11 +102,13 @@ "instance:default", "install:default", "process:default", + "reports:default", "cache:default", "files:default", "settings:default", "shortcuts:default", "tags:default", + "users:default", "utils:default", "ads:default", "friends:default", diff --git a/apps/app/src/api/install.rs b/apps/app/src/api/install.rs index 96d2f0481..22dcbef21 100644 --- a/apps/app/src/api/install.rs +++ b/apps/app/src/api/install.rs @@ -6,6 +6,10 @@ use theseus::data::ModLoader; use theseus::install::{ InstallJobSnapshot, InstallModpackPreview, InstallPostInstallEdit, }; +use theseus::instance::{ + SharedInstanceInstallPreview, SharedInstanceInviteInstallPreview, + SharedInstanceUpdatePreview, +}; use theseus::pack::import::ImportLauncherType; use theseus::pack::install_from::CreatePackLocation; use uuid::Uuid; @@ -16,6 +20,11 @@ pub fn init() -> tauri::plugin::TauriPlugin { install_get_modpack_preview, install_create_instance, install_create_modpack_instance, + install_get_shared_instance_preview, + install_accept_shared_instance_invite, + install_get_shared_instance_update_preview, + install_shared_instance, + install_update_shared_instance, install_import_instance, install_duplicate_instance, install_existing_instance, @@ -101,6 +110,67 @@ pub async fn install_create_modpack_instance( .await?) } +#[tauri::command] +pub async fn install_get_shared_instance_preview( + shared_instance_id: String, + name: String, +) -> Result { + Ok(theseus::instance::get_shared_instance_install_preview( + &shared_instance_id, + name, + ) + .await?) +} + +#[tauri::command] +pub async fn install_accept_shared_instance_invite( + invite_id: String, +) -> Result { + Ok( + theseus::instance::accept_shared_instance_invite_for_install( + &invite_id, + ) + .await?, + ) +} + +#[tauri::command] +pub async fn install_get_shared_instance_update_preview( + instance_id: String, +) -> Result> { + Ok( + theseus::instance::get_shared_instance_update_preview(&instance_id) + .await?, + ) +} + +#[tauri::command] +pub async fn install_shared_instance( + shared_instance_id: String, + name: String, + manager_id: Option, + server_manager_name: Option, + server_manager_icon_url: Option, + instance_icon_url: Option, +) -> Result { + Ok(theseus::instance::install_shared_instance( + &shared_instance_id, + name, + manager_id, + server_manager_name, + server_manager_icon_url, + instance_icon_url, + ) + .await?) +} + +#[tauri::command] +pub async fn install_update_shared_instance( + instance_id: String, +) -> Result { + Ok(theseus::instance::update_shared_instance(&instance_id).await?) +} + #[tauri::command] pub async fn install_import_instance( launcher_type: ImportLauncherType, diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs index 1b33adf62..3de25824f 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance.rs @@ -10,6 +10,8 @@ use theseus::data::{ EditInstance as CoreEditInstance, InstanceInstallCandidate, InstanceInstallTarget, InstanceLaunchOverridesPatch, InstanceLink as CoreInstanceLink, InstanceMetadata, LinkedModpackInfo, + SharedInstanceAttachment as CoreSharedInstanceAttachment, + SharedInstanceRole, }; use theseus::instance::InstallProjectWithDependenciesRequest; use theseus::instance::QuickPlayType; @@ -50,6 +52,15 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_kill, instance_edit, instance_edit_icon, + instance_share_can_current_user_use, + instance_share_get_users, + instance_share_invite_users, + instance_share_create_invite_link, + instance_share_remove_users, + instance_share_get_publish_preview, + instance_share_publish, + instance_share_unlink, + instance_share_unpublish, instance_export_mrpack, instance_get_pack_export_candidates, ]) @@ -70,6 +81,8 @@ pub struct Instance { pub loader_version: Option, pub groups: Vec, pub link: Option, + pub shared_instance: Option, + pub quarantined: bool, pub update_channel: ReleaseChannel, pub created: chrono::DateTime, pub modified: chrono::DateTime, @@ -115,10 +128,40 @@ pub enum InstanceLink { active_instance_id: Option, }, SharedInstance { - shared_instance_id: String, + modpack_project_id: Option, + modpack_version_id: Option, }, } +#[derive(Serialize, Debug, Clone)] +pub struct SharedInstanceAttachment { + pub id: String, + pub role: SharedInstanceRole, + pub manager_id: Option, + pub server_manager_name: Option, + pub server_manager_icon_url: Option, + pub linked_user_id: Option, + pub status: String, + pub applied_version: Option, + pub latest_version: Option, +} + +impl From for SharedInstanceAttachment { + fn from(attachment: CoreSharedInstanceAttachment) -> Self { + Self { + id: attachment.id.to_string(), + role: attachment.role, + manager_id: attachment.manager_id, + server_manager_name: attachment.server_manager_name, + server_manager_icon_url: attachment.server_manager_icon_url, + linked_user_id: attachment.linked_user_id, + status: attachment.status.as_str().to_string(), + applied_version: attachment.applied_version, + latest_version: attachment.latest_version, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct EditInstance { pub name: Option, @@ -201,6 +244,8 @@ impl From for Instance { loader_version: metadata.applied_content_set.loader_version, groups: metadata.groups, link: InstanceLink::from_core(metadata.link), + shared_instance: metadata.shared_instance.map(Into::into), + quarantined: metadata.quarantined, update_channel: metadata.instance.update_channel, created: metadata.instance.created, modified: metadata.instance.modified, @@ -268,11 +313,13 @@ impl InstanceLink { .collect(), active_instance_id: active_instance_id.map(|id| id.to_string()), }), - CoreInstanceLink::SharedInstance { shared_instance_id } => { - Some(Self::SharedInstance { - shared_instance_id: shared_instance_id.to_string(), - }) - } + CoreInstanceLink::SharedInstance { + modpack_project_id, + modpack_version_id, + } => Some(Self::SharedInstance { + modpack_project_id, + modpack_version_id, + }), } } @@ -345,19 +392,13 @@ impl InstanceLink { }) .transpose()?, }), - Self::SharedInstance { shared_instance_id } => { - Ok(CoreInstanceLink::SharedInstance { - shared_instance_id: shared_instance_id.parse().map_err( - |err| { - theseus::Error::from( - theseus::ErrorKind::InputError(format!( - "Invalid shared instance id: {err}" - )), - ) - }, - )?, - }) - } + Self::SharedInstance { + modpack_project_id, + modpack_version_id, + } => Ok(CoreInstanceLink::SharedInstance { + modpack_project_id, + modpack_version_id, + }), } } } @@ -744,3 +785,90 @@ pub async fn instance_edit_icon( theseus::instance::edit_icon(instance_id, icon_path).await?; Ok(()) } + +#[tauri::command] +pub async fn instance_share_can_current_user_use() -> Result { + Ok(theseus::instance::can_active_user_use_shared_instances().await?) +} + +#[tauri::command] +pub async fn instance_share_get_users( + instance_id: &str, +) -> Result { + Ok(theseus::instance::get_shared_instance_users(instance_id).await?) +} + +#[tauri::command] +pub async fn instance_share_invite_users( + instance_id: &str, + user_ids: Vec, +) -> Result { + Ok( + theseus::instance::invite_shared_instance_users(instance_id, user_ids) + .await?, + ) +} + +#[tauri::command] +pub async fn instance_share_create_invite_link( + instance_id: &str, + max_age_seconds: Option, + max_uses: Option, + replace_invite_id: Option, +) -> Result { + Ok(theseus::instance::create_shared_instance_invite_link( + instance_id, + max_age_seconds, + max_uses, + replace_invite_id, + ) + .await?) +} + +#[tauri::command] +pub async fn instance_share_remove_users( + instance_id: &str, + user_ids: Vec, + has_pending_recipients: bool, +) -> Result { + Ok(theseus::instance::remove_shared_instance_users( + instance_id, + user_ids, + has_pending_recipients, + ) + .await?) +} + +#[tauri::command] +pub async fn instance_share_get_publish_preview( + instance_id: &str, +) -> Result> { + Ok( + theseus::instance::get_shared_instance_publish_preview(instance_id) + .await?, + ) +} + +#[tauri::command] +pub async fn instance_share_publish( + instance_id: &str, + config_paths: Vec, +) -> Result { + Ok( + theseus::instance::publish_shared_instance(instance_id, config_paths) + .await? + .into(), + ) +} + +#[tauri::command] +pub async fn instance_share_unlink(instance_id: &str) -> Result<()> { + theseus::instance::unlink_shared_instance(instance_id).await?; + Ok(()) +} + +#[tauri::command] +pub async fn instance_share_unpublish(instance_id: &str) -> Result<()> { + theseus::instance::unpublish_shared_instance(instance_id).await?; + Ok(()) +} diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 2d45357cb..acbe75b55 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -12,9 +12,11 @@ pub mod metadata; pub mod minecraft_skins; pub mod mr_auth; pub mod process; +pub mod reports; pub mod settings; pub mod shortcuts; pub mod tags; +pub mod users; pub mod utils; pub mod ads; @@ -85,9 +87,20 @@ macro_rules! impl_serialize { TheseusSerializableError::Theseus(theseus_error) => { $crate::error::display_tracing_error(theseus_error); - let mut state = serializer.serialize_struct("Theseus", 2)?; + let unavailable_reason = match theseus_error.raw.as_ref() { + theseus::ErrorKind::SharedInstanceUnavailable(reason) => Some(reason), + _ => None, + }; + let mut state = serializer.serialize_struct( + "Theseus", + if unavailable_reason.is_some() { 4 } else { 2 }, + )?; state.serialize_field("field_name", "Theseus")?; state.serialize_field("message", &theseus_error.to_string())?; + if let Some(reason) = unavailable_reason { + state.serialize_field("code", "shared_instance_unavailable")?; + state.serialize_field("reason", reason)?; + } state.end() } $( diff --git a/apps/app/src/api/mr_auth.rs b/apps/app/src/api/mr_auth.rs index 2143d20c5..5aba3132a 100644 --- a/apps/app/src/api/mr_auth.rs +++ b/apps/app/src/api/mr_auth.rs @@ -22,6 +22,7 @@ pub fn init() -> TauriPlugin { #[tauri::command] pub async fn modrinth_login( app: tauri::AppHandle, + flow: mr_auth::ModrinthAuthFlow, ) -> Result { let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel(); let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen( @@ -32,7 +33,7 @@ pub async fn modrinth_login( let auth_request_uri = format!( "{}?launcher=true&ipver={}&port={}", - mr_auth::authenticate_begin_flow(), + mr_auth::authenticate_begin_flow(flow), if auth_code_recv_socket.is_ipv4() { "4" } else { diff --git a/apps/app/src/api/reports.rs b/apps/app/src/api/reports.rs new file mode 100644 index 000000000..3ee5ceb10 --- /dev/null +++ b/apps/app/src/api/reports.rs @@ -0,0 +1,15 @@ +use crate::api::Result; +use theseus::reports::{CreateReportRequest, CreateReportResponse}; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("reports") + .invoke_handler(tauri::generate_handler![reports_create]) + .build() +} + +#[tauri::command] +pub async fn reports_create( + request: CreateReportRequest, +) -> Result { + Ok(theseus::reports::create_report(request).await?) +} diff --git a/apps/app/src/api/users.rs b/apps/app/src/api/users.rs new file mode 100644 index 000000000..b4c5dba75 --- /dev/null +++ b/apps/app/src/api/users.rs @@ -0,0 +1,13 @@ +use crate::api::Result; +use theseus::users::SearchUser; + +#[tauri::command] +pub async fn search_user(query: &str) -> Result> { + Ok(theseus::users::search_user(query).await?) +} + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("users") + .invoke_handler(tauri::generate_handler![search_user]) + .build() +} diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 96d6b9e33..2d70869a6 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -152,7 +152,7 @@ fn main() { builder = builder .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { if let Some(payload) = args.get(1) { - tracing::info!("Handling deep link from arg {payload}"); + tracing::info!("Handling command-line deep link"); let payload = payload.clone(); tauri::async_runtime::spawn(api::utils::handle_command( payload, @@ -197,7 +197,7 @@ fn main() { .unwrap_or(request); tauri::async_runtime::spawn(async move { - tracing::info!("Handling deep link {actual_request}"); + tracing::info!("Handling macOS deep link"); let mut payload = mtx_copy_copy.lock().await; if payload.is_none() { @@ -213,7 +213,7 @@ fn main() { #[cfg(not(target_os = "macos"))] app.listen("deep-link://new-url", |url| { let payload = url.payload().to_owned(); - tracing::info!("Handling deep link {payload}"); + tracing::info!("Handling deep link"); tauri::async_runtime::spawn(api::utils::handle_command( payload, )); @@ -240,9 +240,11 @@ fn main() { .plugin(api::metadata::init()) .plugin(api::minecraft_skins::init()) .plugin(api::process::init()) + .plugin(api::reports::init()) .plugin(api::settings::init()) .plugin(api::shortcuts::init()) .plugin(api::tags::init()) + .plugin(api::users::init()) .plugin(api::utils::init()) .plugin(api::cache::init()) .plugin(api::files::init()) diff --git a/apps/frontend/.env.local b/apps/frontend/.env.local index 29aad1d10..c41cb5f51 100644 --- a/apps/frontend/.env.local +++ b/apps/frontend/.env.local @@ -1,4 +1,5 @@ BASE_URL=http://127.0.0.1:8000/v2/ BROWSER_BASE_URL=http://127.0.0.1:8000/v2/ PYRO_BASE_URL=https://staging-archon.modrinth.com +SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com PROD_OVERRIDE=true diff --git a/apps/frontend/.env.prod b/apps/frontend/.env.prod index 8aebc57d5..6c1f4b15c 100644 --- a/apps/frontend/.env.prod +++ b/apps/frontend/.env.prod @@ -1,4 +1,5 @@ BASE_URL=https://api.modrinth.com/v2/ BROWSER_BASE_URL=https://api.modrinth.com/v2/ PYRO_BASE_URL=https://archon.modrinth.com +SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com PROD_OVERRIDE=true diff --git a/apps/frontend/.env.staging b/apps/frontend/.env.staging index 3dd5f73d3..59e9c8700 100644 --- a/apps/frontend/.env.staging +++ b/apps/frontend/.env.staging @@ -1,4 +1,5 @@ BASE_URL=https://staging-api.modrinth.com/v2/ BROWSER_BASE_URL=https://staging-api.modrinth.com/v2/ PYRO_BASE_URL=https://staging-archon.modrinth.com +SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com PROD_OVERRIDE=true diff --git a/apps/frontend/.env.staging-archon b/apps/frontend/.env.staging-archon index 4f733625a..b5f0713f7 100644 --- a/apps/frontend/.env.staging-archon +++ b/apps/frontend/.env.staging-archon @@ -1,4 +1,5 @@ BASE_URL=https://api.modrinth.com/v2/ BROWSER_BASE_URL=https://api.modrinth.com/v2/ PYRO_BASE_URL=https://staging-archon.modrinth.com +SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com PROD_OVERRIDE=true diff --git a/apps/frontend/nuxt.config.ts b/apps/frontend/nuxt.config.ts index 0d14d0c54..7322ac32a 100644 --- a/apps/frontend/nuxt.config.ts +++ b/apps/frontend/nuxt.config.ts @@ -7,6 +7,7 @@ import svgLoader from 'vite-svg-loader' import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts' const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/' +const STAGING_SHARED_INSTANCES_API_URL = 'https://staging-shared-instances.modrinth.com' const API_CLIENT_SOURCE = fileURLToPath( new URL('../../packages/api-client/src/index.ts', import.meta.url), ) @@ -210,6 +211,7 @@ export default defineNuxtConfig({ // @ts-ignore rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY, pyroBaseUrl: process.env.PYRO_BASE_URL, + sharedInstancesBaseUrl: getSharedInstancesApiUrl(), intercomIdentitySecret: process.env.INTERCOM_IDENTITY_SECRET ?? // @ts-ignore @@ -217,6 +219,7 @@ export default defineNuxtConfig({ public: { apiBaseUrl: getApiUrl(), pyroBaseUrl: process.env.PYRO_BASE_URL, + sharedInstancesBaseUrl: getSharedInstancesApiUrl(), siteUrl: getDomain(), intercomAppId: process.env.INTERCOM_APP_ID || @@ -354,6 +357,15 @@ function getApiUrl() { return process.env.BROWSER_BASE_URL ?? globalThis.BROWSER_BASE_URL ?? STAGING_API_URL } +function getSharedInstancesApiUrl() { + return ( + process.env.SHARED_INSTANCES_API_BASE_URL ?? + // @ts-ignore + globalThis.SHARED_INSTANCES_API_BASE_URL ?? + STAGING_SHARED_INSTANCES_API_URL + ) +} + function isProduction() { return process.env.NODE_ENV === 'production' } diff --git a/apps/frontend/src/components/ui/SharedInstanceInviteOpenInAppModal.vue b/apps/frontend/src/components/ui/SharedInstanceInviteOpenInAppModal.vue new file mode 100644 index 000000000..541bb86c2 --- /dev/null +++ b/apps/frontend/src/components/ui/SharedInstanceInviteOpenInAppModal.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue index e2c2b935f..aa10338c3 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue @@ -89,14 +89,18 @@
-
+
{{ reportItemTitle }} + + {{ reportItemTitle }} +
{{ report.version.files.find((f) => f.primary)?.filename || 'Unknown Version' }} + + Version {{ report.shared_instance_version_id }} + + + + + Quarantined +
@@ -135,6 +153,24 @@ {{ report.target.name }}
+
+ + + {{ sharedInstanceDetails.owner.username }} + +
@@ -154,6 +190,39 @@ :closed="reportClosed" @update-thread="updateThread" > + diff --git a/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue b/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue new file mode 100644 index 000000000..3f3b0d9ad --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue @@ -0,0 +1,442 @@ + + + + + diff --git a/apps/frontend/src/components/ui/report/ReportInfo.vue b/apps/frontend/src/components/ui/report/ReportInfo.vue index 3c4c619eb..3f584a0d5 100644 --- a/apps/frontend/src/components/ui/report/ReportInfo.vue +++ b/apps/frontend/src/components/ui/report/ReportInfo.vue @@ -63,6 +63,18 @@
+
+
+ +
+
+ Shared instance + + Version {{ report.shared_instance_version_id ?? 'unknown' }} · + + +
+
@@ -106,7 +118,7 @@ + + diff --git a/apps/frontend/src/providers/setup/modrinth-client.ts b/apps/frontend/src/providers/setup/modrinth-client.ts index 0f5d13ede..a6f2c8911 100644 --- a/apps/frontend/src/providers/setup/modrinth-client.ts +++ b/apps/frontend/src/providers/setup/modrinth-client.ts @@ -7,6 +7,7 @@ export function setupModrinthClientProvider(auth: Awaited AbstractModule @@ -128,6 +132,10 @@ export const MODULE_REGISTRY = { labrinth_versions_v3: LabrinthVersionsV3Module, paper_versions_v3: PaperVersionsV3Module, purpur_versions_v2: PurpurVersionsV2Module, + sharedinstances_invites_v1: SharedInstancesInvitesV1Module, + sharedinstances_instances_v1: SharedInstancesInstancesV1Module, + sharedinstances_moderation_v1: SharedInstancesModerationV1Module, + sharedinstances_users_v1: SharedInstancesUsersV1Module, } as const satisfies Record export type ModuleID = keyof typeof MODULE_REGISTRY diff --git a/packages/api-client/src/modules/labrinth/images/v3.ts b/packages/api-client/src/modules/labrinth/images/v3.ts index 7613f0deb..c9e058523 100644 --- a/packages/api-client/src/modules/labrinth/images/v3.ts +++ b/packages/api-client/src/modules/labrinth/images/v3.ts @@ -21,7 +21,7 @@ function buildImageQueryParams( params.thread_message_id = target.thread_message_id break case 'report': - params.report_id = target.report_id + if (target.report_id) params.report_id = target.report_id break } return params diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 6e823c614..9789cc6df 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -434,7 +434,7 @@ export namespace Labrinth { | { context: 'project'; project_id: string } | { context: 'version'; version_id: string } | { context: 'thread_message'; thread_message_id: string } - | { context: 'report'; report_id: string } + | { context: 'report'; report_id: string | null } ) export type UploadedImageFor = Extract< @@ -450,7 +450,7 @@ export namespace Labrinth { | { context: 'project'; project_id: string } | { context: 'version'; version_id: string } | { context: 'thread_message'; thread_message_id: string } - | { context: 'report'; report_id: string } + | { context: 'report'; report_id?: string } } } @@ -1917,13 +1917,14 @@ export namespace Labrinth { export namespace Reports { export namespace v3 { - export type ItemType = 'project' | 'version' | 'user' | 'unknown' + export type ItemType = 'project' | 'version' | 'user' | 'shared-instance' | 'unknown' export type Report = { id: string report_type: string item_id: string item_type: ItemType + shared_instance_version_id?: number reporter: string body: string created: string diff --git a/packages/api-client/src/modules/shared-instances/instances/v1.ts b/packages/api-client/src/modules/shared-instances/instances/v1.ts new file mode 100644 index 000000000..7b9581046 --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/instances/v1.ts @@ -0,0 +1,66 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { SharedInstances } from '../types' + +export class SharedInstancesInstancesV1Module extends AbstractModule { + public getModuleID(): string { + return 'sharedinstances_instances_v1' + } + + public async get(instanceId: string): Promise { + return this.client.request( + `/instances/${encodeURIComponent(instanceId)}`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } + + public async getForUser(userId: string): Promise { + return this.client.request('/instances', { + api: 'sharedinstances', + version: 1, + method: 'GET', + params: { user: userId }, + }) + } + + public async getUsers(instanceId: string): Promise { + return this.client.request( + `/instances/${encodeURIComponent(instanceId)}/users`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } + + public async getLatestVersion( + instanceId: string, + ): Promise { + return this.client.request( + `/instances/${encodeURIComponent(instanceId)}/versions`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } + + public async getVersion( + instanceId: string, + version: number, + ): Promise { + return this.client.request( + `/instances/${encodeURIComponent(instanceId)}/versions/${version}`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } +} diff --git a/packages/api-client/src/modules/shared-instances/invites/v1.ts b/packages/api-client/src/modules/shared-instances/invites/v1.ts new file mode 100644 index 000000000..72536cd0a --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/invites/v1.ts @@ -0,0 +1,21 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { SharedInstances } from '../types' + +export class SharedInstancesInvitesV1Module extends AbstractModule { + public getModuleID(): string { + return 'sharedinstances_invites_v1' + } + + public async get(inviteId: string): Promise { + return this.client.request( + `/invites/${encodeURIComponent(inviteId)}`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + skipAuth: true, + retry: false, + }, + ) + } +} diff --git a/packages/api-client/src/modules/shared-instances/moderation/v1.ts b/packages/api-client/src/modules/shared-instances/moderation/v1.ts new file mode 100644 index 000000000..b70056da9 --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/moderation/v1.ts @@ -0,0 +1,41 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { SharedInstances } from '../types' + +export class SharedInstancesModerationV1Module extends AbstractModule { + public getModuleID(): string { + return 'sharedinstances_moderation_v1' + } + + public async blacklistUsers( + request: SharedInstances.Moderation.v1.BlacklistUserRequest, + ): Promise { + return this.client.request('/moderation/blacklist', { + api: 'sharedinstances', + version: 1, + method: 'POST', + body: request, + }) + } + + public async unblacklistUsers( + request: SharedInstances.Moderation.v1.BlacklistUserRequest, + ): Promise { + return this.client.request('/moderation/blacklist', { + api: 'sharedinstances', + version: 1, + method: 'DELETE', + body: request, + }) + } + + public async deleteFile(instanceId: string, version: number, fileName: string): Promise { + return this.client.request( + `/moderation/instances/${encodeURIComponent(instanceId)}/versions/${version}/files/${encodeURIComponent(fileName)}`, + { + api: 'sharedinstances', + version: 1, + method: 'DELETE', + }, + ) + } +} diff --git a/packages/api-client/src/modules/shared-instances/types.ts b/packages/api-client/src/modules/shared-instances/types.ts new file mode 100644 index 000000000..4ad1e3afb --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/types.ts @@ -0,0 +1,101 @@ +export namespace SharedInstances { + export namespace Invites { + export namespace v1 { + export type UserManager = { + id: string + name: string + type: 'user' + avatar?: string | null + } + + export type ServerManager = { + name: string + type: 'server' + icon?: string | null + } + + export type Manager = UserManager | ServerManager + + export type InviteUser = { + id: string + name: string + avatar?: string | null + joined_at: string | null + } + + export type Invite = { + instance_id: string + instance_name: string + instance_icon?: string | null + game_version: string + loader_version: string + managers: Manager[] + instance_users?: InviteUser[] + } + } + } + + export namespace Instances { + export namespace v1 { + export type Instance = { + name: string + icon: string | null + quarantine: boolean + } + + export type JoinType = 'owner' | 'invite' | 'link' + + export type InstanceUser = { + id: string + joined_at: string | null + join_type: JoinType + last_played: string | null + } + + export type InstanceUsers = { + users: InstanceUser[] + tokens: number + } + + export type FileMetadata = { + path: string + hash: string + } + + export type ExternalFile = { + file_name: string + file_type: string + url: string + file_size?: number + metadata?: FileMetadata[] + } + + export type InstanceVersion = { + version: number + modrinth_ids?: string[] + ready: boolean + external_files: ExternalFile[] + modpack_id: string | null + game_version: string + loader: string + loader_version: string + } + } + } + + export namespace Moderation { + export namespace v1 { + export type BlacklistUserRequest = { + user_ids: string[] + } + } + } + + export namespace Users { + export namespace v1 { + export type BlacklistStatus = { + blacklisted: boolean + } + } + } +} diff --git a/packages/api-client/src/modules/shared-instances/users/v1.ts b/packages/api-client/src/modules/shared-instances/users/v1.ts new file mode 100644 index 000000000..fafed1965 --- /dev/null +++ b/packages/api-client/src/modules/shared-instances/users/v1.ts @@ -0,0 +1,21 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { SharedInstances } from '../types' + +export class SharedInstancesUsersV1Module extends AbstractModule { + public getModuleID(): string { + return 'sharedinstances_users_v1' + } + + public async getBlacklistStatus( + userId: string, + ): Promise { + return this.client.request( + `/blacklist/${encodeURIComponent(userId)}`, + { + api: 'sharedinstances', + version: 1, + method: 'GET', + }, + ) + } +} diff --git a/packages/api-client/src/modules/types.ts b/packages/api-client/src/modules/types.ts index d86e87723..bd25e5f70 100644 --- a/packages/api-client/src/modules/types.ts +++ b/packages/api-client/src/modules/types.ts @@ -6,3 +6,4 @@ export * from './launcher-meta/types' export * from './mclogs/types' export * from './paper/types' export * from './purpur/types' +export * from './shared-instances/types' diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts index 7d6016d5b..4bef70372 100644 --- a/packages/api-client/src/platform/xhr-upload-client.ts +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -21,6 +21,8 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) + } else if (options.api === 'sharedinstances') { + baseUrl = this.resolveBaseUrl(this.config.sharedInstancesBaseUrl!) } else { baseUrl = options.api } diff --git a/packages/api-client/src/types/client.ts b/packages/api-client/src/types/client.ts index 57d33105e..b2b991cc4 100644 --- a/packages/api-client/src/types/client.ts +++ b/packages/api-client/src/types/client.ts @@ -50,6 +50,12 @@ export interface ClientConfig { */ archonBaseUrl?: BaseUrlConfig + /** + * Base URL for the Shared Instances API + * @default 'https://shared-instances.modrinth.com' + */ + sharedInstancesBaseUrl?: BaseUrlConfig + /** * Default request timeout in milliseconds * @default 10000 diff --git a/packages/api-client/src/types/request.ts b/packages/api-client/src/types/request.ts index c1545e578..d1a57d884 100644 --- a/packages/api-client/src/types/request.ts +++ b/packages/api-client/src/types/request.ts @@ -11,6 +11,7 @@ export type RequestOptions = { * API to use for this request * - 'labrinth': Main Modrinth API (resolves to labrinthBaseUrl) * - 'archon': Modrinth Hosting API (resolves to archonBaseUrl) + * - 'sharedinstances': Shared Instances API (resolves to sharedInstancesBaseUrl) * - string: Custom base URL (e.g., 'https://custom-api.com') */ api: 'labrinth' | 'archon' | string diff --git a/packages/app-lib/.env.local b/packages/app-lib/.env.local index 198438ffe..0fa04b5dd 100644 --- a/packages/app-lib/.env.local +++ b/packages/app-lib/.env.local @@ -1,5 +1,6 @@ MODRINTH_URL=http://localhost:3000/ MODRINTH_API_BASE_URL=http://localhost:8000/ +SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/ MODRINTH_API_URL=http://127.0.0.1:8000/v2/ MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/ MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/ diff --git a/packages/app-lib/.env.prod b/packages/app-lib/.env.prod index 94f2c2d34..8459f8a61 100644 --- a/packages/app-lib/.env.prod +++ b/packages/app-lib/.env.prod @@ -1,5 +1,6 @@ MODRINTH_URL=https://modrinth.com/ MODRINTH_API_BASE_URL=https://api.modrinth.com/ +SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/ MODRINTH_ARCHON_BASE_URL=https://archon.modrinth.com/ MODRINTH_API_URL=https://api.modrinth.com/v2/ MODRINTH_API_URL_V3=https://api.modrinth.com/v3/ diff --git a/packages/app-lib/.env.prod-with-staging-archon b/packages/app-lib/.env.prod-with-staging-archon index b2d549c0e..d27b54338 100644 --- a/packages/app-lib/.env.prod-with-staging-archon +++ b/packages/app-lib/.env.prod-with-staging-archon @@ -1,5 +1,6 @@ MODRINTH_URL=https://modrinth.com/ MODRINTH_API_BASE_URL=https://api.modrinth.com/ +SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/ MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/ MODRINTH_API_URL=https://api.modrinth.com/v2/ MODRINTH_API_URL_V3=https://api.modrinth.com/v3/ diff --git a/packages/app-lib/.env.staging b/packages/app-lib/.env.staging index 0094b73e5..7b6fe6a17 100644 --- a/packages/app-lib/.env.staging +++ b/packages/app-lib/.env.staging @@ -1,5 +1,6 @@ MODRINTH_URL=https://staging.modrinth.com/ MODRINTH_API_BASE_URL=https://staging-api.modrinth.com/ +SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/ MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/ MODRINTH_API_URL=https://staging-api.modrinth.com/v2/ MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/ diff --git a/packages/app-lib/.sqlx/query-0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f.json b/packages/app-lib/.sqlx/query-0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f.json new file mode 100644 index 000000000..b4cb1c6ed --- /dev/null +++ b/packages/app-lib/.sqlx/query-0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f.json @@ -0,0 +1,296 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?", + "describe": { + "columns": [ + { + "name": "id!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "applied_content_set_id?: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "install_stage!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "launcher_feature_version!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "update_channel!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "icon_path?: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "created!: i64", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "modified!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_played?: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "submitted_time_played!: i64", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "recent_time_played!: i64", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "content_set_id?: String", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "content_set_instance_id?: String", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "content_set_name?: String", + "ordinal": 15, + "type_info": "Text" + }, + { + "name": "content_set_source_kind?: String", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "content_set_status?: String", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "content_set_game_version?: String", + "ordinal": 18, + "type_info": "Text" + }, + { + "name": "content_set_protocol_version?: i64", + "ordinal": 19, + "type_info": "Integer" + }, + { + "name": "content_set_loader?: String", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "content_set_loader_version?: String", + "ordinal": 21, + "type_info": "Text" + }, + { + "name": "content_set_created?: i64", + "ordinal": 22, + "type_info": "Integer" + }, + { + "name": "content_set_modified?: i64", + "ordinal": 23, + "type_info": "Integer" + }, + { + "name": "link_kind!: String", + "ordinal": 24, + "type_info": "Null" + }, + { + "name": "modrinth_project_id?: String", + "ordinal": 25, + "type_info": "Text" + }, + { + "name": "modrinth_version_id?: String", + "ordinal": 26, + "type_info": "Text" + }, + { + "name": "server_project_id?: String", + "ordinal": 27, + "type_info": "Text" + }, + { + "name": "content_project_id?: String", + "ordinal": 28, + "type_info": "Text" + }, + { + "name": "content_version_id?: String", + "ordinal": 29, + "type_info": "Text" + }, + { + "name": "hosting_server_id?: String", + "ordinal": 30, + "type_info": "Text" + }, + { + "name": "hosting_instance_ids?: String", + "ordinal": 31, + "type_info": "Null" + }, + { + "name": "hosting_active_instance_id?: String", + "ordinal": 32, + "type_info": "Text" + }, + { + "name": "shared_instance_id?: String", + "ordinal": 33, + "type_info": "Text" + }, + { + "name": "shared_instance_role?: String", + "ordinal": 34, + "type_info": "Text" + }, + { + "name": "shared_instance_manager_id?: String", + "ordinal": 35, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_name?: String", + "ordinal": 36, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_icon_url?: String", + "ordinal": 37, + "type_info": "Text" + }, + { + "name": "shared_instance_linked_user_id?: String", + "ordinal": 38, + "type_info": "Text" + }, + { + "name": "shared_sync_applied_update_id?: String", + "ordinal": 39, + "type_info": "Text" + }, + { + "name": "shared_sync_latest_available_update_id?: String", + "ordinal": 40, + "type_info": "Text" + }, + { + "name": "shared_sync_status?: String", + "ordinal": 41, + "type_info": "Text" + }, + { + "name": "imported_name?: String", + "ordinal": 42, + "type_info": "Text" + }, + { + "name": "imported_version_number?: String", + "ordinal": 43, + "type_info": "Text" + }, + { + "name": "imported_filename?: String", + "ordinal": 44, + "type_info": "Text" + }, + { + "name": "groups!: String", + "ordinal": 45, + "type_info": "Null" + }, + { + "name": "launch_overrides?: String", + "ordinal": 46, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + null, + true, + true, + true, + true, + true, + true, + null, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null, + null + ] + }, + "hash": "0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f" +} diff --git a/packages/app-lib/.sqlx/query-2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc.json b/packages/app-lib/.sqlx/query-2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc.json new file mode 100644 index 000000000..76d7ed3fc --- /dev/null +++ b/packages/app-lib/.sqlx/query-2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO instance_content_set_remote_refs (\n\t\t\tcontent_set_id,\n\t\t\tref_type,\n\t\t\tref_id\n\t\t)\n\t\tVALUES (?, ?, ?)\n\t\tON CONFLICT (content_set_id, ref_type) DO UPDATE SET\n\t\t\tref_id = excluded.ref_id\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc" +} diff --git a/packages/app-lib/.sqlx/query-474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7.json b/packages/app-lib/.sqlx/query-474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7.json deleted file mode 100644 index 14967a6a1..000000000 --- a/packages/app-lib/.sqlx/query-474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n '[]' AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ", - "describe": { - "columns": [ - { - "name": "id!: String", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "path!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "applied_content_set_id?: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "install_stage!: String", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "launcher_feature_version!: String", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "update_channel!: String", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "name!: String", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "icon_path?: String", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created!: i64", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "modified!: i64", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "last_played?: i64", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "submitted_time_played!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "recent_time_played!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "content_set_id?: String", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "content_set_instance_id?: String", - "ordinal": 14, - "type_info": "Text" - }, - { - "name": "content_set_name?: String", - "ordinal": 15, - "type_info": "Text" - }, - { - "name": "content_set_source_kind?: String", - "ordinal": 16, - "type_info": "Text" - }, - { - "name": "content_set_status?: String", - "ordinal": 17, - "type_info": "Text" - }, - { - "name": "content_set_game_version?: String", - "ordinal": 18, - "type_info": "Text" - }, - { - "name": "content_set_protocol_version?: i64", - "ordinal": 19, - "type_info": "Integer" - }, - { - "name": "content_set_loader?: String", - "ordinal": 20, - "type_info": "Text" - }, - { - "name": "content_set_loader_version?: String", - "ordinal": 21, - "type_info": "Text" - }, - { - "name": "content_set_created?: i64", - "ordinal": 22, - "type_info": "Integer" - }, - { - "name": "content_set_modified?: i64", - "ordinal": 23, - "type_info": "Integer" - }, - { - "name": "link_kind!: String", - "ordinal": 24, - "type_info": "Null" - }, - { - "name": "modrinth_project_id?: String", - "ordinal": 25, - "type_info": "Text" - }, - { - "name": "modrinth_version_id?: String", - "ordinal": 26, - "type_info": "Text" - }, - { - "name": "server_project_id?: String", - "ordinal": 27, - "type_info": "Text" - }, - { - "name": "content_project_id?: String", - "ordinal": 28, - "type_info": "Text" - }, - { - "name": "content_version_id?: String", - "ordinal": 29, - "type_info": "Text" - }, - { - "name": "hosting_server_id?: String", - "ordinal": 30, - "type_info": "Text" - }, - { - "name": "hosting_instance_ids?: String", - "ordinal": 31, - "type_info": "Null" - }, - { - "name": "hosting_active_instance_id?: String", - "ordinal": 32, - "type_info": "Text" - }, - { - "name": "shared_instance_id?: String", - "ordinal": 33, - "type_info": "Text" - }, - { - "name": "imported_name?: String", - "ordinal": 34, - "type_info": "Text" - }, - { - "name": "imported_version_number?: String", - "ordinal": 35, - "type_info": "Text" - }, - { - "name": "imported_filename?: String", - "ordinal": 36, - "type_info": "Text" - }, - { - "name": "groups!: String", - "ordinal": 37, - "type_info": "Null" - }, - { - "name": "launch_overrides?: String", - "ordinal": 38, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - true, - false, - false, - false, - false, - true, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - null, - true, - true, - true, - true, - true, - true, - null, - true, - true, - true, - true, - true, - null, - null - ] - }, - "hash": "474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7" -} diff --git a/packages/app-lib/.sqlx/query-6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57.json b/packages/app-lib/.sqlx/query-6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57.json new file mode 100644 index 000000000..ad566bf3d --- /dev/null +++ b/packages/app-lib/.sqlx/query-6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO instance_content_set_sync_state (\n\t\t\tcontent_set_id,\n\t\t\tprovider,\n\t\t\tapplied_update_id,\n\t\t\tlatest_available_update_id,\n\t\t\tchecked_at,\n\t\t\tstatus\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (content_set_id) DO UPDATE SET\n\t\t\tprovider = excluded.provider,\n\t\t\tapplied_update_id = excluded.applied_update_id,\n\t\t\tlatest_available_update_id = excluded.latest_available_update_id,\n\t\t\tchecked_at = excluded.checked_at,\n\t\t\tstatus = excluded.status\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57" +} diff --git a/packages/app-lib/.sqlx/query-26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281.json b/packages/app-lib/.sqlx/query-6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa.json similarity index 73% rename from packages/app-lib/.sqlx/query-26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281.json rename to packages/app-lib/.sqlx/query-6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa.json index d0a642ead..f7efeaa54 100644 --- a/packages/app-lib/.sqlx/query-26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281.json +++ b/packages/app-lib/.sqlx/query-6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ", + "query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n shared_instance_role,\n shared_instance_manager_id,\n shared_instance_linked_user_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ", "describe": { "columns": [ { @@ -59,19 +59,34 @@ "type_info": "Text" }, { - "name": "imported_name", + "name": "shared_instance_role", "ordinal": 11, "type_info": "Text" }, { - "name": "imported_version_number", + "name": "shared_instance_manager_id", "ordinal": 12, "type_info": "Text" }, { - "name": "imported_filename", + "name": "shared_instance_linked_user_id", "ordinal": 13, "type_info": "Text" + }, + { + "name": "imported_name", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "imported_version_number", + "ordinal": 15, + "type_info": "Text" + }, + { + "name": "imported_filename", + "ordinal": 16, + "type_info": "Text" } ], "parameters": { @@ -91,8 +106,11 @@ true, true, true, + true, + true, + true, true ] }, - "hash": "26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281" + "hash": "6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa" } diff --git a/packages/app-lib/.sqlx/query-76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652.json b/packages/app-lib/.sqlx/query-76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652.json new file mode 100644 index 000000000..b829fe372 --- /dev/null +++ b/packages/app-lib/.sqlx/query-76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tDELETE FROM instance_content_set_remote_refs\n\t\tWHERE content_set_id = ? AND ref_type = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652" +} diff --git a/packages/app-lib/.sqlx/query-88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a.json b/packages/app-lib/.sqlx/query-88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a.json new file mode 100644 index 000000000..9372caf04 --- /dev/null +++ b/packages/app-lib/.sqlx/query-88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a.json @@ -0,0 +1,296 @@ +{ + "db_name": "SQLite", + "query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n \n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n \n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n \n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord", + "describe": { + "columns": [ + { + "name": "id!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "applied_content_set_id?: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "install_stage!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "launcher_feature_version!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "update_channel!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "icon_path?: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "created!: i64", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "modified!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_played?: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "submitted_time_played!: i64", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "recent_time_played!: i64", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "content_set_id?: String", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "content_set_instance_id?: String", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "content_set_name?: String", + "ordinal": 15, + "type_info": "Text" + }, + { + "name": "content_set_source_kind?: String", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "content_set_status?: String", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "content_set_game_version?: String", + "ordinal": 18, + "type_info": "Text" + }, + { + "name": "content_set_protocol_version?: i64", + "ordinal": 19, + "type_info": "Integer" + }, + { + "name": "content_set_loader?: String", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "content_set_loader_version?: String", + "ordinal": 21, + "type_info": "Text" + }, + { + "name": "content_set_created?: i64", + "ordinal": 22, + "type_info": "Integer" + }, + { + "name": "content_set_modified?: i64", + "ordinal": 23, + "type_info": "Integer" + }, + { + "name": "link_kind!: String", + "ordinal": 24, + "type_info": "Null" + }, + { + "name": "modrinth_project_id?: String", + "ordinal": 25, + "type_info": "Text" + }, + { + "name": "modrinth_version_id?: String", + "ordinal": 26, + "type_info": "Text" + }, + { + "name": "server_project_id?: String", + "ordinal": 27, + "type_info": "Text" + }, + { + "name": "content_project_id?: String", + "ordinal": 28, + "type_info": "Text" + }, + { + "name": "content_version_id?: String", + "ordinal": 29, + "type_info": "Text" + }, + { + "name": "hosting_server_id?: String", + "ordinal": 30, + "type_info": "Text" + }, + { + "name": "hosting_instance_ids?: String", + "ordinal": 31, + "type_info": "Null" + }, + { + "name": "hosting_active_instance_id?: String", + "ordinal": 32, + "type_info": "Text" + }, + { + "name": "shared_instance_id?: String", + "ordinal": 33, + "type_info": "Text" + }, + { + "name": "shared_instance_role?: String", + "ordinal": 34, + "type_info": "Text" + }, + { + "name": "shared_instance_manager_id?: String", + "ordinal": 35, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_name?: String", + "ordinal": 36, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_icon_url?: String", + "ordinal": 37, + "type_info": "Text" + }, + { + "name": "shared_instance_linked_user_id?: String", + "ordinal": 38, + "type_info": "Text" + }, + { + "name": "shared_sync_applied_update_id?: String", + "ordinal": 39, + "type_info": "Text" + }, + { + "name": "shared_sync_latest_available_update_id?: String", + "ordinal": 40, + "type_info": "Text" + }, + { + "name": "shared_sync_status?: String", + "ordinal": 41, + "type_info": "Text" + }, + { + "name": "imported_name?: String", + "ordinal": 42, + "type_info": "Text" + }, + { + "name": "imported_version_number?: String", + "ordinal": 43, + "type_info": "Text" + }, + { + "name": "imported_filename?: String", + "ordinal": 44, + "type_info": "Text" + }, + { + "name": "groups!: String", + "ordinal": 45, + "type_info": "Null" + }, + { + "name": "launch_overrides?: String", + "ordinal": 46, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + null, + true, + true, + true, + true, + true, + true, + null, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null, + null + ] + }, + "hash": "88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a" +} diff --git a/packages/app-lib/.sqlx/query-aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04.json b/packages/app-lib/.sqlx/query-aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04.json new file mode 100644 index 000000000..0e3aa4541 --- /dev/null +++ b/packages/app-lib/.sqlx/query-aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04.json @@ -0,0 +1,296 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE 1 = ?", + "describe": { + "columns": [ + { + "name": "id!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "applied_content_set_id?: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "install_stage!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "launcher_feature_version!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "update_channel!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "icon_path?: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "created!: i64", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "modified!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_played?: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "submitted_time_played!: i64", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "recent_time_played!: i64", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "content_set_id?: String", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "content_set_instance_id?: String", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "content_set_name?: String", + "ordinal": 15, + "type_info": "Text" + }, + { + "name": "content_set_source_kind?: String", + "ordinal": 16, + "type_info": "Text" + }, + { + "name": "content_set_status?: String", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "content_set_game_version?: String", + "ordinal": 18, + "type_info": "Text" + }, + { + "name": "content_set_protocol_version?: i64", + "ordinal": 19, + "type_info": "Integer" + }, + { + "name": "content_set_loader?: String", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "content_set_loader_version?: String", + "ordinal": 21, + "type_info": "Text" + }, + { + "name": "content_set_created?: i64", + "ordinal": 22, + "type_info": "Integer" + }, + { + "name": "content_set_modified?: i64", + "ordinal": 23, + "type_info": "Integer" + }, + { + "name": "link_kind!: String", + "ordinal": 24, + "type_info": "Text" + }, + { + "name": "modrinth_project_id?: String", + "ordinal": 25, + "type_info": "Text" + }, + { + "name": "modrinth_version_id?: String", + "ordinal": 26, + "type_info": "Text" + }, + { + "name": "server_project_id?: String", + "ordinal": 27, + "type_info": "Text" + }, + { + "name": "content_project_id?: String", + "ordinal": 28, + "type_info": "Text" + }, + { + "name": "content_version_id?: String", + "ordinal": 29, + "type_info": "Text" + }, + { + "name": "hosting_server_id?: String", + "ordinal": 30, + "type_info": "Text" + }, + { + "name": "hosting_instance_ids?: String", + "ordinal": 31, + "type_info": "Null" + }, + { + "name": "hosting_active_instance_id?: String", + "ordinal": 32, + "type_info": "Text" + }, + { + "name": "shared_instance_id?: String", + "ordinal": 33, + "type_info": "Text" + }, + { + "name": "shared_instance_role?: String", + "ordinal": 34, + "type_info": "Text" + }, + { + "name": "shared_instance_manager_id?: String", + "ordinal": 35, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_name?: String", + "ordinal": 36, + "type_info": "Text" + }, + { + "name": "shared_instance_server_manager_icon_url?: String", + "ordinal": 37, + "type_info": "Text" + }, + { + "name": "shared_instance_linked_user_id?: String", + "ordinal": 38, + "type_info": "Text" + }, + { + "name": "shared_sync_applied_update_id?: String", + "ordinal": 39, + "type_info": "Text" + }, + { + "name": "shared_sync_latest_available_update_id?: String", + "ordinal": 40, + "type_info": "Text" + }, + { + "name": "shared_sync_status?: String", + "ordinal": 41, + "type_info": "Text" + }, + { + "name": "imported_name?: String", + "ordinal": 42, + "type_info": "Text" + }, + { + "name": "imported_version_number?: String", + "ordinal": 43, + "type_info": "Text" + }, + { + "name": "imported_filename?: String", + "ordinal": 44, + "type_info": "Text" + }, + { + "name": "groups!: String", + "ordinal": 45, + "type_info": "Text" + }, + { + "name": "launch_overrides?: String", + "ordinal": 46, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + null, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null + ] + }, + "hash": "aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04" +} diff --git a/packages/app-lib/.sqlx/query-b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c.json b/packages/app-lib/.sqlx/query-b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c.json new file mode 100644 index 000000000..1f5b4f5af --- /dev/null +++ b/packages/app-lib/.sqlx/query-b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tshared_instance_id,\n\t\t\tshared_instance_role,\n\t\t\tshared_instance_manager_id,\n\t\t\tshared_instance_server_manager_name,\n\t\t\tshared_instance_server_manager_icon_url,\n\t\t\tshared_instance_linked_user_id\n\t\t)\n\t\tVALUES (?, 'unmanaged', ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = CASE\n\t\t\t\tWHEN excluded.shared_instance_id IS NULL\n\t\t\t\t\tAND instance_links.link_kind = 'shared_instance'\n\t\t\t\t\tTHEN 'unmanaged'\n\t\t\t\tELSE instance_links.link_kind\n\t\t\tEND,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\tshared_instance_role = excluded.shared_instance_role,\n\t\t\tshared_instance_manager_id = excluded.shared_instance_manager_id,\n\t\t\tshared_instance_server_manager_name = excluded.shared_instance_server_manager_name,\n\t\t\tshared_instance_server_manager_icon_url = excluded.shared_instance_server_manager_icon_url,\n\t\t\tshared_instance_linked_user_id = excluded.shared_instance_linked_user_id\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c" +} diff --git a/packages/app-lib/.sqlx/query-bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d.json b/packages/app-lib/.sqlx/query-bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d.json deleted file mode 100644 index 3b13db920..000000000 --- a/packages/app-lib/.sqlx/query-bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ", - "describe": { - "columns": [ - { - "name": "id!: String", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "path!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "applied_content_set_id?: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "install_stage!: String", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "launcher_feature_version!: String", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "update_channel!: String", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "name!: String", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "icon_path?: String", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created!: i64", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "modified!: i64", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "last_played?: i64", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "submitted_time_played!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "recent_time_played!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "content_set_id?: String", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "content_set_instance_id?: String", - "ordinal": 14, - "type_info": "Text" - }, - { - "name": "content_set_name?: String", - "ordinal": 15, - "type_info": "Text" - }, - { - "name": "content_set_source_kind?: String", - "ordinal": 16, - "type_info": "Text" - }, - { - "name": "content_set_status?: String", - "ordinal": 17, - "type_info": "Text" - }, - { - "name": "content_set_game_version?: String", - "ordinal": 18, - "type_info": "Text" - }, - { - "name": "content_set_protocol_version?: i64", - "ordinal": 19, - "type_info": "Integer" - }, - { - "name": "content_set_loader?: String", - "ordinal": 20, - "type_info": "Text" - }, - { - "name": "content_set_loader_version?: String", - "ordinal": 21, - "type_info": "Text" - }, - { - "name": "content_set_created?: i64", - "ordinal": 22, - "type_info": "Integer" - }, - { - "name": "content_set_modified?: i64", - "ordinal": 23, - "type_info": "Integer" - }, - { - "name": "link_kind!: String", - "ordinal": 24, - "type_info": "Null" - }, - { - "name": "modrinth_project_id?: String", - "ordinal": 25, - "type_info": "Text" - }, - { - "name": "modrinth_version_id?: String", - "ordinal": 26, - "type_info": "Text" - }, - { - "name": "server_project_id?: String", - "ordinal": 27, - "type_info": "Text" - }, - { - "name": "content_project_id?: String", - "ordinal": 28, - "type_info": "Text" - }, - { - "name": "content_version_id?: String", - "ordinal": 29, - "type_info": "Text" - }, - { - "name": "hosting_server_id?: String", - "ordinal": 30, - "type_info": "Text" - }, - { - "name": "hosting_instance_ids?: String", - "ordinal": 31, - "type_info": "Null" - }, - { - "name": "hosting_active_instance_id?: String", - "ordinal": 32, - "type_info": "Text" - }, - { - "name": "shared_instance_id?: String", - "ordinal": 33, - "type_info": "Text" - }, - { - "name": "imported_name?: String", - "ordinal": 34, - "type_info": "Text" - }, - { - "name": "imported_version_number?: String", - "ordinal": 35, - "type_info": "Text" - }, - { - "name": "imported_filename?: String", - "ordinal": 36, - "type_info": "Text" - }, - { - "name": "groups!: String", - "ordinal": 37, - "type_info": "Null" - }, - { - "name": "launch_overrides?: String", - "ordinal": 38, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - true, - false, - false, - false, - false, - true, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - null, - true, - true, - true, - true, - true, - true, - null, - true, - true, - true, - true, - true, - null, - null - ] - }, - "hash": "bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d" -} diff --git a/packages/app-lib/.sqlx/query-becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243.json b/packages/app-lib/.sqlx/query-becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243.json new file mode 100644 index 000000000..809392531 --- /dev/null +++ b/packages/app-lib/.sqlx/query-becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tDELETE FROM instance_content_set_sync_state\n\t\tWHERE content_set_id = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243" +} diff --git a/packages/app-lib/.sqlx/query-bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b.json b/packages/app-lib/.sqlx/query-bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b.json new file mode 100644 index 000000000..c26399515 --- /dev/null +++ b/packages/app-lib/.sqlx/query-bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b" +} diff --git a/packages/app-lib/.sqlx/query-e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306.json b/packages/app-lib/.sqlx/query-e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306.json deleted file mode 100644 index def78302d..000000000 --- a/packages/app-lib/.sqlx/query-e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord\n ", - "describe": { - "columns": [ - { - "name": "id!: String", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "path!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "applied_content_set_id?: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "install_stage!: String", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "launcher_feature_version!: String", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "update_channel!: String", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "name!: String", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "icon_path?: String", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created!: i64", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "modified!: i64", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "last_played?: i64", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "submitted_time_played!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "recent_time_played!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "content_set_id?: String", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "content_set_instance_id?: String", - "ordinal": 14, - "type_info": "Text" - }, - { - "name": "content_set_name?: String", - "ordinal": 15, - "type_info": "Text" - }, - { - "name": "content_set_source_kind?: String", - "ordinal": 16, - "type_info": "Text" - }, - { - "name": "content_set_status?: String", - "ordinal": 17, - "type_info": "Text" - }, - { - "name": "content_set_game_version?: String", - "ordinal": 18, - "type_info": "Text" - }, - { - "name": "content_set_protocol_version?: i64", - "ordinal": 19, - "type_info": "Integer" - }, - { - "name": "content_set_loader?: String", - "ordinal": 20, - "type_info": "Text" - }, - { - "name": "content_set_loader_version?: String", - "ordinal": 21, - "type_info": "Text" - }, - { - "name": "content_set_created?: i64", - "ordinal": 22, - "type_info": "Integer" - }, - { - "name": "content_set_modified?: i64", - "ordinal": 23, - "type_info": "Integer" - }, - { - "name": "link_kind!: String", - "ordinal": 24, - "type_info": "Null" - }, - { - "name": "modrinth_project_id?: String", - "ordinal": 25, - "type_info": "Text" - }, - { - "name": "modrinth_version_id?: String", - "ordinal": 26, - "type_info": "Text" - }, - { - "name": "server_project_id?: String", - "ordinal": 27, - "type_info": "Text" - }, - { - "name": "content_project_id?: String", - "ordinal": 28, - "type_info": "Text" - }, - { - "name": "content_version_id?: String", - "ordinal": 29, - "type_info": "Text" - }, - { - "name": "hosting_server_id?: String", - "ordinal": 30, - "type_info": "Text" - }, - { - "name": "hosting_instance_ids?: String", - "ordinal": 31, - "type_info": "Null" - }, - { - "name": "hosting_active_instance_id?: String", - "ordinal": 32, - "type_info": "Text" - }, - { - "name": "shared_instance_id?: String", - "ordinal": 33, - "type_info": "Text" - }, - { - "name": "imported_name?: String", - "ordinal": 34, - "type_info": "Text" - }, - { - "name": "imported_version_number?: String", - "ordinal": 35, - "type_info": "Text" - }, - { - "name": "imported_filename?: String", - "ordinal": 36, - "type_info": "Text" - }, - { - "name": "groups!: String", - "ordinal": 37, - "type_info": "Null" - }, - { - "name": "launch_overrides?: String", - "ordinal": 38, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - true, - false, - false, - false, - false, - true, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - null, - true, - true, - true, - true, - true, - true, - null, - true, - true, - true, - true, - true, - null, - null - ] - }, - "hash": "e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306" -} diff --git a/packages/app-lib/.sqlx/query-eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8.json b/packages/app-lib/.sqlx/query-eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8.json deleted file mode 100644 index 1086063a2..000000000 --- a/packages/app-lib/.sqlx/query-eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\tshared_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8" -} diff --git a/packages/app-lib/.sqlx/query-f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b.json b/packages/app-lib/.sqlx/query-f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b.json deleted file mode 100644 index 4fcd16770..000000000 --- a/packages/app-lib/.sqlx/query-f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ", - "describe": { - "columns": [ - { - "name": "id!: String", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "path!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "applied_content_set_id?: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "install_stage!: String", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "launcher_feature_version!: String", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "update_channel!: String", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "name!: String", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "icon_path?: String", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created!: i64", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "modified!: i64", - "ordinal": 9, - "type_info": "Integer" - }, - { - "name": "last_played?: i64", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "submitted_time_played!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "recent_time_played!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "content_set_id?: String", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "content_set_instance_id?: String", - "ordinal": 14, - "type_info": "Text" - }, - { - "name": "content_set_name?: String", - "ordinal": 15, - "type_info": "Text" - }, - { - "name": "content_set_source_kind?: String", - "ordinal": 16, - "type_info": "Text" - }, - { - "name": "content_set_status?: String", - "ordinal": 17, - "type_info": "Text" - }, - { - "name": "content_set_game_version?: String", - "ordinal": 18, - "type_info": "Text" - }, - { - "name": "content_set_protocol_version?: i64", - "ordinal": 19, - "type_info": "Integer" - }, - { - "name": "content_set_loader?: String", - "ordinal": 20, - "type_info": "Text" - }, - { - "name": "content_set_loader_version?: String", - "ordinal": 21, - "type_info": "Text" - }, - { - "name": "content_set_created?: i64", - "ordinal": 22, - "type_info": "Integer" - }, - { - "name": "content_set_modified?: i64", - "ordinal": 23, - "type_info": "Integer" - }, - { - "name": "link_kind!: String", - "ordinal": 24, - "type_info": "Text" - }, - { - "name": "modrinth_project_id?: String", - "ordinal": 25, - "type_info": "Text" - }, - { - "name": "modrinth_version_id?: String", - "ordinal": 26, - "type_info": "Text" - }, - { - "name": "server_project_id?: String", - "ordinal": 27, - "type_info": "Text" - }, - { - "name": "content_project_id?: String", - "ordinal": 28, - "type_info": "Text" - }, - { - "name": "content_version_id?: String", - "ordinal": 29, - "type_info": "Text" - }, - { - "name": "hosting_server_id?: String", - "ordinal": 30, - "type_info": "Text" - }, - { - "name": "hosting_instance_ids?: String", - "ordinal": 31, - "type_info": "Null" - }, - { - "name": "hosting_active_instance_id?: String", - "ordinal": 32, - "type_info": "Text" - }, - { - "name": "shared_instance_id?: String", - "ordinal": 33, - "type_info": "Text" - }, - { - "name": "imported_name?: String", - "ordinal": 34, - "type_info": "Text" - }, - { - "name": "imported_version_number?: String", - "ordinal": 35, - "type_info": "Text" - }, - { - "name": "imported_filename?: String", - "ordinal": 36, - "type_info": "Text" - }, - { - "name": "groups!: String", - "ordinal": 37, - "type_info": "Text" - }, - { - "name": "launch_overrides?: String", - "ordinal": 38, - "type_info": "Null" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - true, - false, - false, - false, - false, - true, - false, - false, - true, - false, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - false, - true, - true, - true, - true, - true, - true, - null, - true, - true, - true, - true, - true, - false, - null - ] - }, - "hash": "f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b" -} diff --git a/packages/app-lib/migrations/20260630120000_shared-instance-role.sql b/packages/app-lib/migrations/20260630120000_shared-instance-role.sql new file mode 100644 index 000000000..0ee944c28 --- /dev/null +++ b/packages/app-lib/migrations/20260630120000_shared-instance-role.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_links + ADD COLUMN shared_instance_role TEXT NULL; diff --git a/packages/app-lib/migrations/20260707103000_shared-instance-manager.sql b/packages/app-lib/migrations/20260707103000_shared-instance-manager.sql new file mode 100644 index 000000000..beadec3d2 --- /dev/null +++ b/packages/app-lib/migrations/20260707103000_shared-instance-manager.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_links + ADD COLUMN shared_instance_manager_id TEXT NULL; diff --git a/packages/app-lib/migrations/20260707110000_shared-instance-linked-user.sql b/packages/app-lib/migrations/20260707110000_shared-instance-linked-user.sql new file mode 100644 index 000000000..b4fa60ab5 --- /dev/null +++ b/packages/app-lib/migrations/20260707110000_shared-instance-linked-user.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_links + ADD COLUMN shared_instance_linked_user_id TEXT NULL; diff --git a/packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql b/packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql new file mode 100644 index 000000000..066d18f2f --- /dev/null +++ b/packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql @@ -0,0 +1,5 @@ +ALTER TABLE instance_links + ADD COLUMN shared_instance_server_manager_name TEXT NULL; + +ALTER TABLE instance_links + ADD COLUMN shared_instance_server_manager_icon_url TEXT NULL; diff --git a/packages/app-lib/migrations/20260723120000_quarantined-instances.sql b/packages/app-lib/migrations/20260723120000_quarantined-instances.sql new file mode 100644 index 000000000..0fa1e1103 --- /dev/null +++ b/packages/app-lib/migrations/20260723120000_quarantined-instances.sql @@ -0,0 +1,4 @@ +CREATE TABLE instance_quarantines ( + instance_id TEXT PRIMARY KEY NOT NULL, + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); diff --git a/packages/app-lib/src/api/handler.rs b/packages/app-lib/src/api/handler.rs index 8a5351c03..b35a0bf2b 100644 --- a/packages/app-lib/src/api/handler.rs +++ b/packages/app-lib/src/api/handler.rs @@ -30,6 +30,26 @@ pub async fn handle_url(sublink: &str) -> crate::Result { Some(("server", id)) => { CommandPayload::InstallServer { id: id.to_string() } } + // /share/{invite_id} + Some(("share", raw)) => { + let (raw, _) = raw.split_once('?').unwrap_or((raw, "")); + + match decode(raw) { + Ok(decoded) => CommandPayload::InstallSharedInstanceInvite { + invite_id: decoded.to_string(), + }, + Err(e) => { + emit_warning(&format!( + "Invalid UTF-8 in shared instance invite path: {e}" + )) + .await?; + return Err(crate::ErrorKind::InputError(format!( + "Invalid UTF-8 in shared instance invite path: {e}" + )) + .into()); + } + } + } // /launch/instance/{id} - Launches an instance Some(("launch", rest)) if rest.starts_with("instance/") => { let raw = rest.trim_start_matches("instance/"); @@ -93,7 +113,7 @@ pub async fn handle_url(sublink: &str) -> crate::Result { pub async fn parse_command( command_string: &str, ) -> crate::Result { - tracing::debug!("Parsing command: {}", &command_string); + tracing::debug!("Parsing external command"); // modrinth://some-command // This occurs when following a web redirect link diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs index bcaf873f8..ad30a0780 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance.rs @@ -1,6 +1,7 @@ //! Theseus instance management interface mod content; +mod content_set_diff; mod export_mrpack; mod get; mod install; @@ -8,6 +9,7 @@ mod lifecycle; mod paths; mod projects; mod run; +mod shared; pub use self::content::{ get_content_items, get_dependencies_as_content_items, @@ -33,3 +35,24 @@ pub use self::projects::{ pub use self::run::{ QuickPlayType, kill, run, try_update_playtime_by_instance_id, }; +pub(crate) use self::shared::{ + CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS, + CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES, + read_bounded_config_bundle_entry, +}; +pub use self::shared::{ + SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, + SharedInstanceInviteInstallPreview, SharedInstanceInviteLink, + SharedInstanceJoinType, SharedInstancePublishPreview, + SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, + SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, + accept_pending_shared_instance_invite, + accept_shared_instance_invite_for_install, + can_active_user_use_shared_instances, create_shared_instance_invite_link, + decline_pending_shared_instance_invite, + get_shared_instance_install_preview, get_shared_instance_publish_preview, + get_shared_instance_update_preview, get_shared_instance_users, + install_shared_instance, invite_shared_instance_users, + publish_shared_instance, remove_shared_instance_users, + unlink_shared_instance, unpublish_shared_instance, update_shared_instance, +}; diff --git a/packages/app-lib/src/api/instance/content_set_diff.rs b/packages/app-lib/src/api/instance/content_set_diff.rs new file mode 100644 index 000000000..57d1e9c17 --- /dev/null +++ b/packages/app-lib/src/api/instance/content_set_diff.rs @@ -0,0 +1,152 @@ +use crate::state::Version; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ContentSetDiffKind { + Added, + Removed, + Updated, +} + +#[derive(Clone, Debug)] +pub(crate) enum ContentSetDiffEntry { + Project { + kind: ContentSetDiffKind, + project_id: String, + current_version_name: Option, + new_version_name: Option, + disabled: bool, + }, + ExternalFile { + kind: ContentSetDiffKind, + file_name: String, + disabled: bool, + }, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ContentSetDiffOptions { + pub removed_disabled_project_ids: HashSet, + pub removed_disabled_external_files: HashSet, + pub common_external_files_are_updated: bool, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ContentSetSnapshot { + pub versions: Vec, + pub external_files: HashSet, +} + +#[derive(Clone, Debug)] +pub(crate) struct ContentSetSnapshotVersion { + pub project_id: String, + pub version_id: String, + pub version_name: String, +} + +impl From for ContentSetSnapshotVersion { + fn from(version: Version) -> Self { + Self { + project_id: version.project_id, + version_id: version.id, + version_name: version.version_number, + } + } +} + +pub(crate) fn diff_content_sets( + current: &ContentSetSnapshot, + latest: &ContentSetSnapshot, + options: &ContentSetDiffOptions, +) -> Vec { + let current_versions = versions_by_project(current); + let latest_versions = versions_by_project(latest); + let project_ids = current_versions + .keys() + .chain(latest_versions.keys()) + .copied() + .collect::>(); + + let mut diffs = Vec::new(); + for project_id in project_ids { + let current = current_versions.get(project_id); + let latest = latest_versions.get(project_id); + + match (current, latest) { + (None, Some(latest)) => { + diffs.push(ContentSetDiffEntry::Project { + kind: ContentSetDiffKind::Added, + project_id: project_id.to_string(), + current_version_name: None, + new_version_name: Some(latest.version_name.clone()), + disabled: false, + }); + } + (Some(current), None) => { + diffs.push(ContentSetDiffEntry::Project { + kind: ContentSetDiffKind::Removed, + project_id: project_id.to_string(), + current_version_name: Some(current.version_name.clone()), + new_version_name: None, + disabled: options + .removed_disabled_project_ids + .contains(project_id), + }); + } + (Some(current), Some(latest)) + if current.version_id != latest.version_id => + { + diffs.push(ContentSetDiffEntry::Project { + kind: ContentSetDiffKind::Updated, + project_id: project_id.to_string(), + current_version_name: Some(current.version_name.clone()), + new_version_name: Some(latest.version_name.clone()), + disabled: false, + }); + } + _ => {} + } + } + + for file_name in latest.external_files.difference(¤t.external_files) { + diffs.push(ContentSetDiffEntry::ExternalFile { + kind: ContentSetDiffKind::Added, + file_name: file_name.clone(), + disabled: false, + }); + } + + if options.common_external_files_are_updated { + for file_name in + latest.external_files.intersection(¤t.external_files) + { + diffs.push(ContentSetDiffEntry::ExternalFile { + kind: ContentSetDiffKind::Updated, + file_name: file_name.clone(), + disabled: false, + }); + } + } + + for file_name in current.external_files.difference(&latest.external_files) { + diffs.push(ContentSetDiffEntry::ExternalFile { + kind: ContentSetDiffKind::Removed, + file_name: file_name.clone(), + disabled: options + .removed_disabled_external_files + .contains(file_name), + }); + } + + diffs +} + +fn versions_by_project( + snapshot: &ContentSetSnapshot, +) -> HashMap<&str, &ContentSetSnapshotVersion> { + snapshot + .versions + .iter() + .map(|version| (version.project_id.as_str(), version)) + .collect() +} diff --git a/packages/app-lib/src/api/instance/lifecycle.rs b/packages/app-lib/src/api/instance/lifecycle.rs index 0139b6b5f..94b37e1f5 100644 --- a/packages/app-lib/src/api/instance/lifecycle.rs +++ b/packages/app-lib/src/api/instance/lifecycle.rs @@ -101,12 +101,27 @@ pub async fn edit_icon( crate::state::edit_instance( instance_id, EditInstance { - icon_path: Some(icon_path), + icon_path: Some(icon_path.clone()), ..EditInstance::default() }, &state.pool, ) .await?; + + if let Err(error) = super::shared::sync_shared_instance_icon( + instance_id, + icon_path.as_deref(), + &state, + ) + .await + { + tracing::warn!( + instance_id, + error = %error, + "Failed to sync shared instance icon" + ); + } + emit_instance(&instance.id, InstancePayloadType::Edited).await?; Ok(()) diff --git a/packages/app-lib/src/api/instance/projects.rs b/packages/app-lib/src/api/instance/projects.rs index 18c3c98e8..d8dc5f0ee 100644 --- a/packages/app-lib/src/api/instance/projects.rs +++ b/packages/app-lib/src/api/instance/projects.rs @@ -1,7 +1,9 @@ use crate::event::emit::{emit_instance, emit_loading, init_loading}; use crate::event::{InstancePayloadType, LoadingBarType}; use crate::state::instances::adapters::sqlite::instance_rows; -use crate::state::{CacheBehaviour, CachedEntry, ProjectType, State}; +use crate::state::{ + CacheBehaviour, CachedEntry, ContentSourceKind, ProjectType, State, +}; use crate::util::fetch; use modrinth_content_management::{ ContentType, ResolutionPreferences, ResolveContentPlan, @@ -23,6 +25,7 @@ pub async fn update_all_projects( instance_id: &str, ) -> crate::Result> { let state = State::get().await?; + ensure_instance_content_unlocked(instance_id, &state).await?; let instance = get_instance_display_info(instance_id, &state).await?; let loading_bar = init_loading( LoadingBarType::InstanceUpdate { @@ -51,13 +54,18 @@ pub async fn update_project( skip_send_event: Option, ) -> crate::Result { let state = State::get().await?; + ensure_shared_instance_can_modify_project( + instance_id, + project_path, + &state, + ) + .await?; let path = crate::state::instances::commands::update_project( instance_id, project_path, &state, ) .await?; - if !skip_send_event.unwrap_or(false) { emit_instance(instance_id, InstancePayloadType::Edited).await?; } @@ -73,6 +81,7 @@ pub async fn add_project_from_version( dependent_on_version_id: Option, ) -> crate::Result { let state = State::get().await?; + ensure_instance_content_unlocked(instance_id, &state).await?; let project_path = crate::state::instances::commands::add_project_from_version( instance_id, @@ -97,6 +106,7 @@ pub async fn install_project_with_dependencies( let metadata = super::get::get(instance_id).await?.ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; + ensure_metadata_content_unlocked(&metadata)?; let plan = crate::state::instances::commands::resolve_install_plan( instance_id, crate::state::instances::commands::InstanceInstallProjectRequest { @@ -181,6 +191,12 @@ pub async fn switch_project_version_with_dependencies( version_id: &str, ) -> crate::Result { let state = State::get().await?; + ensure_shared_instance_can_modify_project( + instance_id, + project_path, + &state, + ) + .await?; let metadata = super::get::get(instance_id).await?.ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; @@ -204,13 +220,18 @@ pub async fn add_project_from_path( project_type: Option, ) -> crate::Result { let state = State::get().await?; - crate::state::instances::commands::add_project_from_path( - instance_id, - path, - project_type, - &state, - ) - .await + ensure_instance_content_unlocked(instance_id, &state).await?; + let project_path = + crate::state::instances::commands::add_project_from_path( + instance_id, + path, + project_type, + &state, + ) + .await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(project_path) } #[tracing::instrument] @@ -235,6 +256,8 @@ pub async fn toggle_disable_project( desired_enabled: Option, ) -> crate::Result { let state = State::get().await?; + ensure_shared_instance_can_modify_project(instance_id, project, &state) + .await?; let res = crate::state::instances::commands::toggle_disable_project( instance_id, project, @@ -253,6 +276,8 @@ pub async fn remove_project( project: &str, ) -> crate::Result<()> { let state = State::get().await?; + ensure_shared_instance_can_modify_project(instance_id, project, &state) + .await?; crate::state::instances::commands::remove_project( instance_id, project, @@ -264,6 +289,45 @@ pub async fn remove_project( Ok(()) } +async fn ensure_shared_instance_can_modify_project( + instance_id: &str, + project_path: &str, + state: &State, +) -> crate::Result<()> { + let metadata = crate::state::instances::commands::get_instance_metadata( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + ensure_metadata_content_unlocked(&metadata)?; + if !metadata + .shared_instance + .is_some_and(|attachment| attachment.role.is_member()) + { + return Ok(()); + } + + let source_kind = + crate::state::instances::commands::content_source_kind_for_project_path( + instance_id, + project_path, + state, + ) + .await?; + if source_kind.is_some_and(ContentSourceKind::is_shared_instance_managed) { + return Err(crate::ErrorKind::InputError( + "Shared instance managed content cannot be changed directly." + .to_string(), + ) + .into()); + } + + Ok(()) +} + #[tracing::instrument] pub async fn update_managed_modrinth_version( instance_id: &str, @@ -278,6 +342,7 @@ pub async fn update_managed_modrinth_version( .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; + ensure_metadata_content_unlocked(&metadata)?; let post_install_edit = match &metadata.link { crate::state::InstanceLink::ServerProjectModpack { @@ -335,6 +400,7 @@ pub async fn repair_managed_modrinth( .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; + ensure_metadata_content_unlocked(&metadata)?; let post_install_edit = match &metadata.link { crate::state::InstanceLink::ServerProjectModpack { .. } => { @@ -381,6 +447,33 @@ fn unmanaged_pack_error(instance_id: &str) -> crate::ErrorKind { )) } +async fn ensure_instance_content_unlocked( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + if instance_rows::is_instance_quarantined(instance_id, &state.pool).await? { + return Err(quarantined_content_error().into()); + } + + Ok(()) +} + +fn ensure_metadata_content_unlocked( + metadata: &crate::state::InstanceMetadata, +) -> crate::Result<()> { + if metadata.quarantined { + return Err(quarantined_content_error().into()); + } + + Ok(()) +} + +fn quarantined_content_error() -> crate::ErrorKind { + crate::ErrorKind::InputError( + "Content in quarantined instances cannot be changed.".to_string(), + ) +} + async fn get_instance_display_info( instance_id: &str, state: &State, diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs index e4d1706de..e5615dded 100644 --- a/packages/app-lib/src/api/instance/run.rs +++ b/packages/app-lib/src/api/instance/run.rs @@ -24,6 +24,23 @@ pub async fn run( quick_play_type: QuickPlayType, ) -> crate::Result { let state = State::get().await?; + if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined( + instance_id, + &state.pool, + ) + .await? + { + return Err(crate::ErrorKind::InputError( + "This instance has been quarantined".to_string(), + ) + .into()); + } + super::shared::check_shared_instance_availability_before_launch( + instance_id, + &state, + ) + .await?; + let default_account = Credentials::get_default_credential(&state.pool) .await? .ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?; @@ -50,6 +67,17 @@ async fn run_credentials( "Tried to run a nonexistent instance {instance_id}!" )) })?; + if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined( + instance_id, + &state.pool, + ) + .await? + { + return Err(crate::ErrorKind::InputError( + "This instance has been quarantined".to_string(), + ) + .into()); + } let pre_launch_hooks = context .launch_overrides diff --git a/packages/app-lib/src/api/instance/shared/client.rs b/packages/app-lib/src/api/instance/shared/client.rs new file mode 100644 index 000000000..05e00a46b --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/client.rs @@ -0,0 +1,1014 @@ +use super::install::*; +use super::*; + +#[derive(Clone, Copy, Debug)] +pub(super) enum SharedInstancesRequestAuth { + ModrinthSession, + None, +} + +impl SharedInstancesRequestAuth { + pub(super) fn label(self) -> &'static str { + match self { + Self::ModrinthSession => "modrinth_session", + Self::None => "none", + } + } +} + +impl SharedInstanceUnavailableReason { + pub(super) fn from_status(status: StatusCode) -> Option { + match status { + StatusCode::NOT_FOUND => Some(Self::Deleted), + StatusCode::UNAUTHORIZED => Some(Self::AccessRevoked), + _ => None, + } + } +} + +#[derive(Debug)] +pub(super) enum SharedInstanceRemoteResponse { + Available(T), + Unavailable(SharedInstanceUnavailableReason), +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct RemoteInstanceResponse { + #[serde(default)] + pub(super) quarantine: bool, +} + +#[derive(Clone, Debug)] +pub(super) struct ExternalFileCandidate { + pub(super) file_name: String, + pub(super) file_type: String, + pub(super) source: ExternalFileSource, +} + +#[derive(Clone, Debug)] +pub(super) enum ExternalFileSource { + InstanceFile(String), + ConfigBundle(Vec), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(super) struct ConfigFile { + pub(super) path: String, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct ExternalFileData { + pub(super) file_name: String, + pub(super) file_type: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct CreateInstanceResponse { + #[serde(alias = "instance_id")] + pub(super) id: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct CreateInstanceInviteResponse { + pub(super) id: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct BlacklistStatusResponse { + pub(super) blacklisted: bool, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct InstanceInviteInfoResponse { + pub(super) instance_id: String, + pub(super) instance_name: String, + #[serde(default)] + pub(super) instance_icon: Option, + #[serde(default)] + pub(super) managers: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum InstanceInviteManagerResponse { + User { id: String }, + Server { name: String, icon: Option }, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct InstanceVersionResponse { + pub(super) version: i32, + #[serde(default)] + pub(super) modrinth_ids: Vec, + pub(super) ready: bool, + #[serde(default)] + pub(super) external_files: Vec, + #[serde(default)] + pub(super) modpack_id: Option, + pub(super) game_version: String, + pub(super) loader: ModLoader, + pub(super) loader_version: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct ExternalFileResponse { + pub(super) file_name: String, + pub(super) file_type: String, + pub(super) url: String, + #[serde(default)] + pub(super) file_size: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct RemoteSharedInstanceUsers { + #[serde(default)] + pub(super) users: Vec, + #[serde(default)] + pub(super) tokens: i32, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(super) enum RemoteSharedInstanceUsersResponse { + Current(RemoteSharedInstanceUsers), + Legacy(Vec), +} + +impl RemoteSharedInstanceUsersResponse { + pub(super) fn into_shared_users(self) -> SharedInstanceUsers { + match self { + Self::Current(response) => { + SharedInstanceUsers::from_users(response.users, response.tokens) + } + Self::Legacy(user_ids) => { + SharedInstanceUsers::from_user_ids(user_ids) + } + } + } +} + +pub(super) async fn create_remote_instance( + name: String, + state: &State, +) -> crate::Result { + request_json( + "create_instance", + Method::POST, + "/instances", + Some(json!({ "name": name })), + state, + ) + .await +} + +pub(super) async fn get_user_blacklist_status( + user_id: &str, + state: &State, +) -> crate::Result { + request_json( + "get_user_blacklist_status", + Method::GET, + &format!("/blacklist/{user_id}"), + None, + state, + ) + .await +} + +pub(super) async fn delete_remote_instance( + shared_instance_id: &str, + state: &State, +) -> crate::Result<()> { + let operation = "delete_instance"; + let method = Method::DELETE; + let path = format!("/instances/{shared_instance_id}"); + let response = + send_request(operation, method.clone(), &path, None, state).await?; + + if response.status().is_success() + || matches!(response.status(), StatusCode::NOT_FOUND | StatusCode::GONE) + { + return Ok(()); + } + + shared_instances_request_error(operation, method, &path, response).await +} + +pub(super) async fn update_remote_instance( + shared_instance_id: &str, + name: String, + state: &State, +) -> crate::Result<()> { + let operation = "update_instance"; + let method = Method::PATCH; + let path = format!("/instances/{shared_instance_id}"); + let response = send_request( + operation, + method.clone(), + &path, + Some(json!({ "name": name })), + state, + ) + .await?; + + if response.status().is_success() { + return Ok(()); + } + + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + let request_id = response_request_id(&response); + tracing::warn!( + operation, + method = method.as_str(), + path, + status = StatusCode::METHOD_NOT_ALLOWED.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API does not support remote instance updates; skipping name sync" + ); + return Ok(()); + } + + shared_instances_request_error(operation, method, &path, response).await +} + +pub(super) async fn get_remote_instance_access( + shared_instance_id: &str, + state: &State, +) -> crate::Result> { + let operation = "get_instance"; + let method = Method::GET; + let path = format!("/instances/{shared_instance_id}"); + let response = + send_request(operation, method.clone(), &path, None, state).await?; + + if let Some(reason) = + SharedInstanceUnavailableReason::from_status(response.status()) + { + if reason == SharedInstanceUnavailableReason::AccessRevoked + && !active_modrinth_session_is_valid(state).await? + { + return Err(crate::ErrorKind::NoCredentialsError.into()); + } + + return Ok(SharedInstanceRemoteResponse::Unavailable(reason)); + } + + if !response.status().is_success() { + return shared_instances_request_error( + operation, method, &path, response, + ) + .await; + } + + let instance = decode_json_response::( + operation, method, &path, response, + ) + .await?; + if instance.quarantine { + return Ok(SharedInstanceRemoteResponse::Unavailable( + SharedInstanceUnavailableReason::Quarantined, + )); + } + + Ok(SharedInstanceRemoteResponse::Available(())) +} + +pub(super) async fn update_remote_instance_icon( + shared_instance_id: &str, + icon_path: Option<&str>, + state: &State, +) -> crate::Result<()> { + let path = format!("/instances/{shared_instance_id}/icon"); + let Some(icon_path) = icon_path else { + return request_empty( + "delete_instance_icon", + Method::DELETE, + &path, + None, + state, + ) + .await; + }; + + let bytes = crate::util::io::read(icon_path).await?; + let operation = "upload_instance_icon"; + let method = Method::PUT; + let response = + send_bytes_request(operation, method.clone(), &path, bytes, state) + .await?; + + if response.status().is_success() { + return Ok(()); + } + + shared_instances_request_error(operation, method, &path, response).await +} + +pub(super) async fn get_remote_users( + shared_instance_id: &str, + state: &State, +) -> crate::Result { + let users = request_json::( + "get_instance_users", + Method::GET, + &format!("/instances/{shared_instance_id}/users"), + None, + state, + ) + .await?; + + Ok(users.into_shared_users()) +} + +pub(super) async fn get_latest_remote_version( + shared_instance_id: &str, + state: &State, +) -> crate::Result { + match get_latest_remote_version_optional_unavailable( + shared_instance_id, + state, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => Ok(version), + SharedInstanceRemoteResponse::Unavailable( + reason @ SharedInstanceUnavailableReason::AccessRevoked, + ) => { + if !accept_pending_remote_invite(shared_instance_id, state).await? { + return Err(shared_instance_unavailable_error(reason)); + } + + match get_latest_remote_version_optional_unavailable( + shared_instance_id, + state, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => Ok(version), + SharedInstanceRemoteResponse::Unavailable(reason) => { + Err(shared_instance_unavailable_error(reason)) + } + } + } + SharedInstanceRemoteResponse::Unavailable(reason) => { + Err(shared_instance_unavailable_error(reason)) + } + } +} + +pub(super) async fn get_latest_remote_version_optional_unavailable( + shared_instance_id: &str, + state: &State, +) -> crate::Result> { + get_latest_remote_version_optional_unavailable_with_auth( + shared_instance_id, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await +} + +pub(super) async fn get_latest_remote_version_optional_unavailable_with_auth( + shared_instance_id: &str, + state: &State, + auth: SharedInstancesRequestAuth, +) -> crate::Result> { + request_json_optional_unavailable( + "get_latest_instance_version", + Method::GET, + &format!("/instances/{shared_instance_id}/versions"), + None, + state, + auth, + ) + .await +} + +pub(super) async fn add_remote_users( + shared_instance_id: &str, + user_ids: Vec, + state: &State, +) -> crate::Result<()> { + request_empty( + "add_instance_users", + Method::POST, + &format!("/instances/{shared_instance_id}/users"), + Some(json!({ "user_ids": user_ids })), + state, + ) + .await +} + +pub(super) async fn remove_remote_users( + shared_instance_id: &str, + user_ids: Vec, + state: &State, +) -> crate::Result<()> { + request_empty( + "remove_instance_users", + Method::DELETE, + &format!("/instances/{shared_instance_id}/users"), + Some(json!({ "user_ids": user_ids })), + state, + ) + .await +} + +pub(super) async fn accept_pending_remote_invite( + shared_instance_id: &str, + state: &State, +) -> crate::Result { + let operation = "accept_pending_instance_invite"; + let method = Method::POST; + let path = format!("/instances/{shared_instance_id}/invites/pending"); + let response = + send_request(operation, method.clone(), &path, None, state).await?; + + match response.status() { + StatusCode::OK | StatusCode::NO_CONTENT => Ok(true), + StatusCode::NOT_FOUND => Ok(false), + status if status.is_success() => Ok(true), + _ => { + shared_instances_request_error(operation, method, &path, response) + .await + } + } +} + +pub(super) async fn accept_shared_instance_invite( + shared_instance_id: &str, + invite_id: &str, + state: &State, +) -> crate::Result<()> { + let path = format!("/instances/{shared_instance_id}/invites/{invite_id}"); + let log_path = "/instances/:instance_id/invites/:invite_id"; + let operation = "accept_instance_invite"; + let method = Method::POST; + + let response = send_request_with_auth_and_log_path( + operation, + method.clone(), + &path, + log_path, + None, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await?; + if response.status().is_success() { + return Ok(()); + } + + let status = response.status(); + let request_id = response_request_id(&response); + let body = response + .text() + .await + .map_err(reqwest::Error::without_url) + .unwrap_or_default(); + if status == StatusCode::BAD_REQUEST && body.contains("already has access") + { + return Ok(()); + } + + tracing::warn!( + operation, + method = method.as_str(), + path = log_path, + status = status.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API request failed" + ); + Err(crate::ErrorKind::OtherError(format!( + "Shared instances API request {operation} {method} {log_path} failed with status {status}" + )) + .into()) +} + +pub(super) async fn delete_remote_invite( + shared_instance_id: &str, + invite_id: &str, + state: &State, +) -> crate::Result<()> { + let path = format!("/instances/{shared_instance_id}/invites/{invite_id}"); + request_empty_with_log_path( + "delete_instance_invite", + Method::DELETE, + &path, + "/instances/:instance_id/invites/:invite_id", + None, + state, + ) + .await +} + +pub(super) async fn get_shared_instance_invite_info( + invite_id: &str, + state: &State, +) -> crate::Result { + let operation = "get_instance_invite"; + let method = Method::GET; + let path = format!("/invites/{invite_id}"); + let log_path = "/invites/:invite_id"; + let response = send_request_with_auth_and_log_path( + operation, + method.clone(), + &path, + log_path, + None, + state, + SharedInstancesRequestAuth::None, + ) + .await?; + if !response.status().is_success() { + return shared_instances_request_error( + operation, method, log_path, response, + ) + .await; + } + + decode_json_response_with_log_path( + operation, method, log_path, response, true, + ) + .await +} + +pub(super) async fn decline_pending_remote_invite( + shared_instance_id: &str, + state: &State, +) -> crate::Result<()> { + request_empty( + "decline_pending_instance_invite", + Method::DELETE, + &format!("/instances/{shared_instance_id}/invites/pending"), + None, + state, + ) + .await +} + +pub(super) async fn request_json( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, +) -> crate::Result +where + T: DeserializeOwned, +{ + let response = + request(operation, method.clone(), path, body, state).await?; + decode_json_response(operation, method, path, response).await +} + +pub(super) async fn request_json_optional_unavailable( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, + auth: SharedInstancesRequestAuth, +) -> crate::Result> +where + T: DeserializeOwned, +{ + let response = send_request_with_auth( + operation, + method.clone(), + path, + body, + state, + auth, + ) + .await?; + if let Some(reason) = + SharedInstanceUnavailableReason::from_status(response.status()) + { + if reason == SharedInstanceUnavailableReason::AccessRevoked + && matches!(auth, SharedInstancesRequestAuth::ModrinthSession) + && !active_modrinth_session_is_valid(state).await? + { + tracing::warn!( + operation, + method = method.as_str(), + path, + status = response.status().as_u16(), + "Shared instances API returned unauthorized while Modrinth auth is unavailable" + ); + return Err(crate::ErrorKind::NoCredentialsError.into()); + } + + tracing::warn!( + operation, + method = method.as_str(), + path, + status = response.status().as_u16(), + "Shared instances API resource is unavailable" + ); + return Ok(SharedInstanceRemoteResponse::Unavailable(reason)); + } + + if !response.status().is_success() { + return shared_instances_request_error( + operation, method, path, response, + ) + .await; + } + + decode_json_response(operation, method, path, response) + .await + .map(SharedInstanceRemoteResponse::Available) +} + +pub(super) async fn active_modrinth_session_is_valid( + state: &State, +) -> crate::Result { + let Some(credentials) = + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + else { + return Ok(false); + }; + + let _permit = state.api_semaphore.0.acquire().await?; + let response = INSECURE_REQWEST_CLIENT + .get(concat!(env!("MODRINTH_API_URL"), "user")) + .header("Authorization", &credentials.session) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED { + ModrinthCredentials::remove(&credentials.user_id, &state.pool).await?; + return Ok(false); + } + + if response.status().is_success() { + return Ok(true); + } + + let status = response.status(); + let request_id = response_request_id(&response); + tracing::warn!( + operation = "validate_modrinth_session", + method = Method::GET.as_str(), + path = "/user", + status = status.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Modrinth auth validation request failed" + ); + Err(crate::ErrorKind::OtherError(format!( + "Modrinth auth validation failed with status {status}" + )) + .into()) +} + +pub(super) async fn decode_json_response( + operation: &'static str, + method: Method, + path: &str, + response: reqwest::Response, +) -> crate::Result +where + T: DeserializeOwned, +{ + decode_json_response_with_log_path(operation, method, path, response, false) + .await +} + +async fn decode_json_response_with_log_path( + operation: &'static str, + method: Method, + log_path: &str, + response: reqwest::Response, + strip_response_url: bool, +) -> crate::Result +where + T: DeserializeOwned, +{ + let status = response.status(); + let request_id = response_request_id(&response); + let body = match response.text().await { + Ok(body) => body, + Err(error) if strip_response_url => { + return Err(error.without_url().into()); + } + Err(error) => return Err(error.into()), + }; + serde_json::from_str::(&body).map_err(|error| { + tracing::warn!( + operation, + method = method.as_str(), + path = log_path, + status = status.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + error_category = ?error.classify(), + error_line = error.line(), + error_column = error.column(), + "Shared instances API returned an invalid JSON response" + ); + crate::ErrorKind::OtherError(format!( + "Shared instances API request {operation} {method} {log_path} returned invalid JSON with status {status}" + )) + .into() + }) +} + +pub(super) async fn request_empty( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, +) -> crate::Result<()> { + request(operation, method, path, body, state).await?; + Ok(()) +} + +async fn request_empty_with_log_path( + operation: &'static str, + method: Method, + path: &str, + log_path: &str, + body: Option, + state: &State, +) -> crate::Result<()> { + let response = send_request_with_auth_and_log_path( + operation, + method.clone(), + path, + log_path, + body, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await?; + if response.status().is_success() { + return Ok(()); + } + + shared_instances_request_error(operation, method, log_path, response).await +} + +pub(super) async fn request( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, +) -> crate::Result { + let response = + send_request(operation, method.clone(), path, body, state).await?; + if response.status().is_success() { + return Ok(response); + } + + shared_instances_request_error(operation, method, path, response).await +} + +pub(super) async fn send_request( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, +) -> crate::Result { + send_request_with_auth( + operation, + method, + path, + body, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await +} + +pub(super) async fn send_bytes_request( + operation: &'static str, + method: Method, + path: &str, + body: Vec, + state: &State, +) -> crate::Result { + let base_url = service_base_url(); + let url = service_url(base_url, path); + send_bytes_request_to_url(operation, method, path, &url, body, state).await +} + +pub(super) async fn send_bytes_request_to_url( + operation: &'static str, + method: Method, + path: &str, + url: &str, + body: Vec, + state: &State, +) -> crate::Result { + let service_origin = url::Url::parse(service_base_url()) + .map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Invalid shared instances API base URL: {error}" + )) + })? + .origin(); + let upload_origin = url::Url::parse(url) + .map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Invalid shared instances upload URL: {error}" + )) + })? + .origin(); + if service_origin != upload_origin { + return Err(crate::ErrorKind::OtherError( + "Shared instances upload URL has an unexpected origin".to_string(), + ) + .into()); + } + + let credentials = + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + .ok_or(crate::ErrorKind::NoCredentialsError)?; + let _permit = state.api_semaphore.0.acquire().await?; + + tracing::debug!( + operation, + method = method.as_str(), + path, + url = %url, + user_id = credentials.user_id.as_str(), + auth = SharedInstancesRequestAuth::ModrinthSession.label(), + has_body = true, + "Sending shared instances API request" + ); + + let response = shared_instances_client(url) + .request(method.clone(), url) + .bearer_auth(credentials.session) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(body) + .send() + .await?; + + if response.status().is_success() { + let request_id = response_request_id(&response); + tracing::debug!( + operation, + method = method.as_str(), + path, + url = %url, + status = response.status().as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API request succeeded" + ); + } + + Ok(response) +} + +pub(super) async fn send_request_with_auth( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, + auth: SharedInstancesRequestAuth, +) -> crate::Result { + send_request_with_auth_and_log_path( + operation, method, path, path, body, state, auth, + ) + .await +} + +async fn send_request_with_auth_and_log_path( + operation: &'static str, + method: Method, + path: &str, + log_path: &str, + body: Option, + state: &State, + auth: SharedInstancesRequestAuth, +) -> crate::Result { + let modrinth_credentials = + if matches!(auth, SharedInstancesRequestAuth::ModrinthSession) { + Some( + ModrinthCredentials::get_and_refresh( + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or(crate::ErrorKind::NoCredentialsError)?, + ) + } else { + None + }; + let _permit = state.api_semaphore.0.acquire().await?; + let base_url = service_base_url(); + let url = service_url(base_url, path); + let log_url = service_url(base_url, log_path); + let mut request = + shared_instances_client(base_url).request(method.clone(), &url); + let mut user_id = None; + + match auth { + SharedInstancesRequestAuth::ModrinthSession => { + let credentials = modrinth_credentials + .expect("Modrinth session credentials were loaded"); + user_id = Some(credentials.user_id); + request = request.bearer_auth(credentials.session); + } + SharedInstancesRequestAuth::None => {} + } + + tracing::debug!( + operation, + method = method.as_str(), + path = log_path, + url = %log_url, + user_id = user_id.as_deref(), + auth = auth.label(), + has_body = body.is_some(), + "Sending shared instances API request" + ); + + if let Some(body) = body { + request = request.json(&body); + } + + let response = match request.send().await { + Ok(response) => response, + Err(error) if path != log_path => { + return Err(error.without_url().into()); + } + Err(error) => return Err(error.into()), + }; + if response.status().is_success() { + let request_id = response_request_id(&response); + tracing::debug!( + operation, + method = method.as_str(), + path = log_path, + url = %log_url, + status = response.status().as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API request succeeded" + ); + } + Ok(response) +} + +pub(super) async fn shared_instances_request_error( + operation: &'static str, + method: Method, + path: &str, + response: reqwest::Response, +) -> crate::Result { + let status = response.status(); + let request_id = response_request_id(&response); + tracing::warn!( + operation, + method = method.as_str(), + path, + status = status.as_u16(), + request_id = request_id.as_deref().unwrap_or("none"), + "Shared instances API request failed" + ); + Err(crate::ErrorKind::OtherError(format!( + "Shared instances API request {operation} {method} {path} failed with status {status}" + )) + .into()) +} + +pub(super) fn response_request_id( + response: &reqwest::Response, +) -> Option { + let request_id = response.headers().get("x-request-id")?.to_str().ok()?; + if request_id.is_empty() + || request_id.len() > 128 + || !request_id.chars().all(|character| { + character.is_ascii_alphanumeric() || "-_.:".contains(character) + }) + { + return None; + } + + Some(request_id.to_string()) +} + +pub(super) fn service_url(base_url: &str, path: &str) -> String { + format!("{base_url}/v1{path}") +} + +pub(super) fn service_base_url() -> &'static str { + env!("SHARED_INSTANCES_API_BASE_URL").trim_end_matches('/') +} + +pub(super) fn shared_instances_client( + base_url: &str, +) -> &'static reqwest::Client { + if base_url.starts_with("https://") { + &REQWEST_CLIENT + } else { + &INSECURE_REQWEST_CLIENT + } +} diff --git a/packages/app-lib/src/api/instance/shared/diff.rs b/packages/app-lib/src/api/instance/shared/diff.rs new file mode 100644 index 000000000..072c9cb66 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/diff.rs @@ -0,0 +1,432 @@ +use super::client::*; +use super::publish::*; +use super::types::*; +use super::*; + +pub(super) async fn shared_instance_update_diffs( + metadata: &crate::state::InstanceMetadata, + version: &InstanceVersionResponse, + state: &State, +) -> crate::Result> { + let remote_modpack_id = + version.modpack_id.as_deref().filter(|id| !id.is_empty()); + let current_modpack_id = shared_modpack_id(&metadata.link); + let modpack_unlinked = + current_modpack_id.is_some() && remote_modpack_id.is_none(); + let (current_version_ids, current_external_files) = + current_shared_content(metadata, modpack_unlinked, state).await?; + let (latest_version_ids, latest_external_files) = + remote_shared_content(version); + let removed_disabled_project_ids = HashSet::new(); + let removed_disabled_external_files = HashSet::new(); + let mut diffs = shared_content_diffs( + ¤t_version_ids, + ¤t_external_files, + &latest_version_ids, + &latest_external_files, + &removed_disabled_project_ids, + &removed_disabled_external_files, + true, + state, + ) + .await?; + let mut configuration_diffs = shared_instance_configuration_diffs( + current_modpack_id.as_deref(), + remote_modpack_id, + &metadata.applied_content_set.game_version, + &version.game_version, + metadata.applied_content_set.loader, + version.loader, + metadata.applied_content_set.loader_version.as_deref(), + Some(version.loader_version.as_str()), + state, + ) + .await?; + configuration_diffs.append(&mut diffs); + + Ok(configuration_diffs) +} + +pub(super) async fn shared_instance_publish_diffs( + metadata: &crate::state::InstanceMetadata, + version: &InstanceVersionResponse, + snapshot: &CurrentPublishSnapshot, + state: &State, +) -> crate::Result> { + let remote_modpack_id = + version.modpack_id.as_deref().filter(|id| !id.is_empty()); + let current_modpack_id = shared_modpack_id(&metadata.link); + let modpack_unlinked = + remote_modpack_id.is_some() && current_modpack_id.is_none(); + let disabled_versions = async { + if snapshot.disabled_version_ids.is_empty() { + Ok(HashMap::new()) + } else { + shared_versions_by_project(&snapshot.disabled_version_ids, state) + .await + } + }; + let ((latest_version_ids, latest_external_files), disabled_versions) = tokio::try_join!( + remote_publish_content(version, modpack_unlinked, state), + disabled_versions, + )?; + let current_external_files = snapshot + .external_files + .iter() + .map(|file| file.file_name.clone()) + .collect::>(); + let current_version_ids = snapshot + .version_ids + .iter() + .filter(|id| current_modpack_id.as_deref() != Some(id.as_str())) + .cloned() + .collect::>(); + let mut removed_disabled_project_ids = + snapshot.disabled_project_ids.clone(); + removed_disabled_project_ids.extend(disabled_versions.into_keys()); + let mut diffs = shared_content_diffs( + &latest_version_ids, + &latest_external_files, + ¤t_version_ids, + ¤t_external_files, + &removed_disabled_project_ids, + &snapshot.disabled_external_files, + false, + state, + ) + .await?; + let mut configuration_diffs = shared_instance_configuration_diffs( + remote_modpack_id, + current_modpack_id.as_deref(), + &version.game_version, + &metadata.applied_content_set.game_version, + version.loader, + metadata.applied_content_set.loader, + Some(version.loader_version.as_str()), + metadata.applied_content_set.loader_version.as_deref(), + state, + ) + .await?; + configuration_diffs.append(&mut diffs); + + Ok(configuration_diffs) +} + +pub(super) async fn shared_instance_configuration_diffs( + current_modpack_id: Option<&str>, + new_modpack_id: Option<&str>, + current_game_version: &str, + new_game_version: &str, + current_loader: ModLoader, + new_loader: ModLoader, + current_loader_version: Option<&str>, + new_loader_version: Option<&str>, + state: &State, +) -> crate::Result> { + let mut diffs = Vec::new(); + + if current_modpack_id != new_modpack_id { + match (current_modpack_id, new_modpack_id) { + (None, Some(_)) => diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::ModpackLinked, + None, + shared_modpack_version_label(new_modpack_id, state).await, + )), + (Some(_), None) => diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::ModpackUnlinked, + shared_modpack_version_label(current_modpack_id, state).await, + None, + )), + (Some(current_modpack_id), Some(new_modpack_id)) => { + let current = + shared_modpack_version_details(current_modpack_id, state) + .await; + let new = + shared_modpack_version_details(new_modpack_id, state).await; + let project_name = new + .as_ref() + .and_then(|details| details.project_name.clone()) + .or_else(|| { + current + .as_ref() + .and_then(|details| details.project_name.clone()) + }); + + diffs.push(SharedInstanceUpdateDiff { + type_: SharedInstanceUpdateDiffType::ModpackUpdated, + project_id: None, + project_name, + file_name: None, + current_version_name: current + .map(|details| details.version_name), + new_version_name: new.map(|details| details.version_name), + config_file_count: None, + disabled: false, + }); + } + (None, None) => unreachable!(), + } + } + + if current_game_version != new_game_version { + diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::GameVersionUpdated, + Some(current_game_version.to_string()), + Some(new_game_version.to_string()), + )); + } + + let current_loader_version = + normalized_loader_version(current_loader_version); + let new_loader_version = normalized_loader_version(new_loader_version); + if current_loader != new_loader + || current_loader_version != new_loader_version + { + diffs.push(configuration_diff( + SharedInstanceUpdateDiffType::LoaderUpdated, + Some(shared_loader_label(current_loader, current_loader_version)), + Some(shared_loader_label(new_loader, new_loader_version)), + )); + } + + Ok(diffs) +} + +pub(super) fn configuration_diff( + type_: SharedInstanceUpdateDiffType, + current_version_name: Option, + new_version_name: Option, +) -> SharedInstanceUpdateDiff { + SharedInstanceUpdateDiff { + type_, + project_id: None, + project_name: None, + file_name: None, + current_version_name, + new_version_name, + config_file_count: None, + disabled: false, + } +} + +pub(super) async fn shared_modpack_version_label( + version_id: Option<&str>, + state: &State, +) -> Option { + let version_id = version_id?; + let details = shared_modpack_version_details(version_id, state).await?; + + Some(match details.project_name { + Some(project_name) => { + format!("{project_name} {}", details.version_name) + } + None => details.version_name, + }) +} + +struct SharedModpackVersionDetails { + project_name: Option, + version_name: String, +} + +async fn shared_modpack_version_details( + version_id: &str, + state: &State, +) -> Option { + let Some(version) = CachedEntry::get_version( + version_id, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await + .ok() + .flatten() else { + return Some(SharedModpackVersionDetails { + project_name: None, + version_name: version_id.to_string(), + }); + }; + let project = CachedEntry::get_project( + &version.project_id, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await + .ok() + .flatten(); + + Some(SharedModpackVersionDetails { + project_name: Some( + project.map(|project| project.title).unwrap_or(version.name), + ), + version_name: version.version_number, + }) +} + +pub(super) fn normalized_loader_version( + loader_version: Option<&str>, +) -> Option<&str> { + loader_version.filter(|version| !version.is_empty()) +} + +pub(super) fn shared_loader_label( + loader: ModLoader, + loader_version: Option<&str>, +) -> String { + let loader_name = match loader { + ModLoader::Vanilla => "Vanilla", + ModLoader::Forge => "Forge", + ModLoader::Fabric => "Fabric", + ModLoader::Quilt => "Quilt", + ModLoader::NeoForge => "NeoForge", + }; + + match loader_version { + Some(version) => format!("{loader_name} {version}"), + None => loader_name.to_string(), + } +} + +pub(super) async fn shared_content_diffs( + current_version_ids: &[String], + current_external_files: &HashSet, + latest_version_ids: &[String], + latest_external_files: &HashSet, + removed_disabled_project_ids: &HashSet, + removed_disabled_external_files: &HashSet, + common_external_files_are_updated: bool, + state: &State, +) -> crate::Result> { + let current = shared_content_snapshot( + current_version_ids, + current_external_files, + state, + ) + .await?; + let latest = shared_content_snapshot( + latest_version_ids, + latest_external_files, + state, + ) + .await?; + let options = ContentSetDiffOptions { + removed_disabled_project_ids: removed_disabled_project_ids.clone(), + removed_disabled_external_files: removed_disabled_external_files + .clone(), + common_external_files_are_updated, + }; + let content_diffs = diff_content_sets(¤t, &latest, &options); + let project_ids = content_diffs + .iter() + .filter_map(|diff| match diff { + ContentSetDiffEntry::Project { project_id, .. } => { + Some(project_id.clone()) + } + ContentSetDiffEntry::ExternalFile { .. } => None, + }) + .collect::>(); + let project_names = shared_project_names(&project_ids, state).await?; + + let mut diffs = Vec::new(); + for diff in content_diffs { + match diff { + ContentSetDiffEntry::Project { + kind, + project_id, + current_version_name, + new_version_name, + disabled, + } => { + let project_name = Some( + project_names + .get(&project_id) + .cloned() + .unwrap_or_else(|| project_id.clone()), + ); + diffs.push(SharedInstanceUpdateDiff { + type_: shared_update_diff_type(kind), + project_id: Some(project_id), + project_name, + file_name: None, + current_version_name, + new_version_name, + config_file_count: None, + disabled, + }); + } + ContentSetDiffEntry::ExternalFile { + kind, + file_name, + disabled, + } => { + diffs.push(SharedInstanceUpdateDiff { + type_: shared_update_diff_type(kind), + project_id: None, + project_name: None, + file_name: Some(file_name), + current_version_name: None, + new_version_name: None, + config_file_count: None, + disabled, + }); + } + } + } + + diffs.sort_by(|a, b| { + a.project_name + .as_deref() + .or(a.file_name.as_deref()) + .cmp(&b.project_name.as_deref().or(b.file_name.as_deref())) + }); + Ok(diffs) +} + +pub(super) fn shared_update_diff_type( + kind: ContentSetDiffKind, +) -> SharedInstanceUpdateDiffType { + match kind { + ContentSetDiffKind::Added => SharedInstanceUpdateDiffType::Added, + ContentSetDiffKind::Removed => SharedInstanceUpdateDiffType::Removed, + ContentSetDiffKind::Updated => SharedInstanceUpdateDiffType::Updated, + } +} + +pub(super) async fn shared_content_snapshot( + version_ids: &[String], + external_files: &HashSet, + state: &State, +) -> crate::Result { + let versions = shared_versions_by_project(version_ids, state) + .await? + .into_values() + .map(ContentSetSnapshotVersion::from) + .collect(); + + Ok(ContentSetSnapshot { + versions, + external_files: external_files.clone(), + }) +} + +pub(super) fn remote_shared_content( + version: &InstanceVersionResponse, +) -> (Vec, HashSet) { + let mut version_ids = version.modrinth_ids.clone(); + if let Some(modpack_id) = version.modpack_id.as_deref() { + version_ids.retain(|id| id != modpack_id); + } + dedupe_strings(&mut version_ids); + + ( + version_ids, + version + .external_files + .iter() + .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .map(|file| file.file_name.clone()) + .collect(), + ) +} diff --git a/packages/app-lib/src/api/instance/shared/install.rs b/packages/app-lib/src/api/instance/shared/install.rs new file mode 100644 index 000000000..85a82a809 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/install.rs @@ -0,0 +1,574 @@ +use super::client::*; +use super::diff::*; +use super::publish::*; +use super::types::*; +use super::*; + +#[tracing::instrument] +pub async fn install_shared_instance( + shared_instance_id: &str, + name: String, + manager_id: Option, + server_manager_name: Option, + server_manager_icon_url: Option, + instance_icon_url: Option, +) -> crate::Result { + let state = State::get().await?; + let version = get_latest_remote_version(shared_instance_id, &state).await?; + let data = shared_instance_install_data( + shared_instance_id, + manager_id, + server_manager_name, + server_manager_icon_url, + instance_icon_url, + name, + version, + &state, + ) + .await?; + + crate::install::create_shared_instance(data).await +} + +pub(super) fn shared_instance_invite_install_name( + shared_instance_id: &str, + name: String, +) -> String { + let name = name.trim(); + if name.is_empty() || name == "Shared instance" { + return format!("Shared instance {shared_instance_id}"); + } + + name.to_string() +} + +#[tracing::instrument] +pub async fn get_shared_instance_install_preview( + shared_instance_id: &str, + name: String, +) -> crate::Result { + let state = State::get().await?; + let version = get_latest_remote_version(shared_instance_id, &state).await?; + shared_instance_install_preview_from_version( + shared_instance_id.to_string(), + name, + version, + &state, + ) + .await +} + +#[tracing::instrument(skip(invite_id))] +pub async fn accept_shared_instance_invite_for_install( + invite_id: &str, +) -> crate::Result { + let state = State::get().await?; + let invite = get_shared_instance_invite_info(invite_id, &state).await?; + let shared_instance_id = invite.instance_id; + let instance_icon_url = invite.instance_icon; + let (manager_id, server_manager_name, server_manager_icon_url) = + shared_instance_invite_manager(invite.managers); + let name = shared_instance_invite_install_name( + &shared_instance_id, + invite.instance_name, + ); + accept_shared_instance_invite(&shared_instance_id, invite_id, &state) + .await?; + let version = + get_latest_remote_version(&shared_instance_id, &state).await?; + let mut preview = shared_instance_install_preview_from_version( + shared_instance_id.clone(), + name, + version, + &state, + ) + .await?; + if instance_icon_url.is_some() { + preview.icon_url.clone_from(&instance_icon_url); + } + + Ok(SharedInstanceInviteInstallPreview { + shared_instance_id, + manager_id, + server_manager_name, + server_manager_icon_url, + instance_icon_url, + preview, + }) +} + +pub(super) fn shared_instance_invite_manager( + managers: Vec, +) -> (Option, Option, Option) { + match managers.into_iter().next() { + Some(InstanceInviteManagerResponse::User { id }) => { + (Some(id), None, None) + } + Some(InstanceInviteManagerResponse::Server { name, icon }) => { + (None, Some(name), icon) + } + None => (None, None, None), + } +} + +pub(super) async fn shared_instance_install_preview_from_version( + shared_instance_id: String, + name: String, + version: InstanceVersionResponse, + state: &State, +) -> crate::Result { + let name = match name.trim() { + "" => "Shared instance".to_string(), + name => name.to_string(), + }; + let mut content_version_ids = version.modrinth_ids.clone(); + let mut seen_content_version_ids = HashSet::new(); + content_version_ids + .retain(|id| seen_content_version_ids.insert(id.clone())); + let modpack = shared_instance_install_modpack(&version, state).await?; + let modpack_dependency_count = modpack + .as_ref() + .map(|modpack| modpack.dependency_count) + .unwrap_or_default(); + let modpack_version_id = + modpack.as_ref().map(|modpack| modpack.version_id.clone()); + if let Some(modpack_version_id) = modpack_version_id.as_deref() { + content_version_ids.retain(|id| id != modpack_version_id); + } + let icon_url = modpack + .as_ref() + .and_then(|modpack| modpack.icon_url.clone()); + + let external_files = version + .external_files + .iter() + .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .map(|file| SharedInstanceExternalFilePreview { + file_name: file.file_name.clone(), + file_type: file.file_type.clone(), + }) + .collect::>(); + let external_file_count = external_files.len(); + + Ok(SharedInstanceInstallPreview { + shared_instance_id, + version: version.version, + name, + icon_url, + game_version: version.game_version, + loader: version.loader, + mod_count: modpack_dependency_count + + content_version_ids.len() + + external_file_count, + external_file_count, + modpack_version_id, + content_version_ids, + external_files, + }) +} + +#[tracing::instrument] +pub async fn get_shared_instance_update_preview( + instance_id: &str, +) -> crate::Result> { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance.clone() else { + return Ok(None); + }; + if !attachment.role.is_member() { + if let SharedInstanceRemoteResponse::Unavailable(reason) = + get_remote_instance_access(&attachment.id, &state).await? + { + handle_unavailable_shared_instance_if_current_user( + instance_id, + &attachment, + reason, + &state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + + return Ok(None); + } + + let version = + get_latest_remote_member_version(instance_id, &attachment, &state) + .await?; + if !version.ready { + return Ok(Some(SharedInstanceUpdatePreview { + shared_instance_id: attachment.id, + current_version: attachment.applied_version, + latest_version: version.version, + update_available: false, + diffs: Vec::new(), + })); + } + + let update_available = attachment + .applied_version + .is_none_or(|current| current < version.version); + let diffs = if update_available { + shared_instance_update_diffs(&metadata, &version, &state).await? + } else { + Vec::new() + }; + + Ok(Some(SharedInstanceUpdatePreview { + shared_instance_id: attachment.id, + current_version: attachment.applied_version, + latest_version: version.version, + update_available, + diffs, + })) +} + +pub(crate) async fn check_shared_instance_availability_before_launch( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance else { + return Ok(()); + }; + + let availability = + match get_remote_instance_access(&attachment.id, state).await { + Ok(availability) => availability, + Err(error) + if matches!( + error.raw.as_ref(), + crate::ErrorKind::NoCredentialsError + | crate::ErrorKind::FetchError(_) + ) => + { + tracing::warn!( + instance_id, + shared_instance_id = %attachment.id, + error = %error, + "Could not check shared instance availability before launch" + ); + return Ok(()); + } + Err(error) => return Err(error), + }; + + if let SharedInstanceRemoteResponse::Unavailable(reason) = availability { + handle_unavailable_shared_instance_if_current_user( + instance_id, + &attachment, + reason, + state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + + Ok(()) +} + +#[tracing::instrument] +pub async fn update_shared_instance( + instance_id: &str, +) -> crate::Result { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let attachment = metadata.shared_instance.clone().ok_or_else(|| { + crate::ErrorKind::InputError( + "Instance is not attached to a shared instance".to_string(), + ) + })?; + if !attachment.role.is_member() { + return Err(crate::ErrorKind::InputError( + "Only shared instance members can update from shared instances" + .to_string(), + ) + .into()); + } + + let version = + get_latest_remote_member_version(instance_id, &attachment, &state) + .await?; + let data = shared_instance_install_data( + &attachment.id, + attachment.manager_id.clone(), + attachment.server_manager_name.clone(), + attachment.server_manager_icon_url.clone(), + None, + metadata.instance.name, + version, + &state, + ) + .await?; + crate::install::update_shared_instance(instance_id.to_string(), data).await +} + +pub(super) async fn ensure_ready_remote_version_for_invite( + instance_id: &str, + attachment: &SharedInstanceAttachment, + state: &State, +) -> crate::Result<()> { + match get_latest_remote_version_optional_unavailable(&attachment.id, state) + .await? + { + SharedInstanceRemoteResponse::Available(_) => Ok(()), + SharedInstanceRemoteResponse::Unavailable( + SharedInstanceUnavailableReason::Deleted, + ) => { + tracing::info!( + instance_id, + shared_instance_id = %attachment.id, + "Shared instance has no ready version before invite; publishing current content" + ); + publish_shared_instance_inner(instance_id, &[], state).await + } + SharedInstanceRemoteResponse::Unavailable(reason) => { + handle_unavailable_shared_instance_if_current_user( + instance_id, + attachment, + reason, + state, + ) + .await?; + Err(shared_instance_unavailable_error(reason)) + } + } +} + +pub(super) async fn get_latest_remote_member_version( + instance_id: &str, + attachment: &SharedInstanceAttachment, + state: &State, +) -> crate::Result { + if let SharedInstanceRemoteResponse::Unavailable(reason) = + get_remote_instance_access(&attachment.id, state).await? + { + if shared_attachment_matches_current_user(attachment, state).await? { + handle_unavailable_shared_instance_if_current_user( + instance_id, + attachment, + reason, + state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + + return Err(crate::ErrorKind::OtherError(format!( + "{}: {}", + shared_instance_unavailable_message(reason), + attachment.id + )) + .into()); + } + + let version = match get_latest_remote_version_optional_unavailable( + &attachment.id, + state, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => version, + SharedInstanceRemoteResponse::Unavailable(reason) => { + if shared_attachment_matches_current_user(attachment, state).await? + { + handle_unavailable_shared_instance_if_current_user( + instance_id, + attachment, + reason, + state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + + return Err(crate::ErrorKind::OtherError(format!( + "{}: {}", + shared_instance_unavailable_message(reason), + attachment.id + )) + .into()); + } + }; + + Ok(version) +} + +pub(super) async fn shared_attachment_matches_current_user( + attachment: &SharedInstanceAttachment, + state: &State, +) -> crate::Result { + let Some(linked_user_id) = attachment.linked_user_id.as_deref() else { + return Ok(false); + }; + + Ok(linked_modrinth_user_id(state) + .await? + .as_deref() + .is_some_and(|current_user_id| current_user_id == linked_user_id)) +} + +pub(super) async fn has_shared_instance_recipients( + users: &SharedInstanceUsers, + attachment: &SharedInstanceAttachment, + has_pending_recipients: bool, + state: &State, +) -> crate::Result { + if has_pending_recipients || users.tokens > 0 { + return Ok(true); + } + + let current_user_id = linked_modrinth_user_id(state).await?; + + Ok(users.user_ids.iter().any(|user_id| { + Some(user_id.as_str()) != attachment.linked_user_id.as_deref() + && Some(user_id.as_str()) != attachment.manager_id.as_deref() + && Some(user_id.as_str()) != current_user_id.as_deref() + })) +} + +pub(super) async fn handle_unavailable_shared_instance_if_current_user( + instance_id: &str, + attachment: &SharedInstanceAttachment, + reason: SharedInstanceUnavailableReason, + state: &State, +) -> crate::Result<()> { + if reason != SharedInstanceUnavailableReason::Quarantined + && !shared_attachment_matches_current_user(attachment, state).await? + { + return Ok(()); + } + + match reason { + SharedInstanceUnavailableReason::Quarantined => { + crate::state::quarantine_shared_instance(instance_id, &state.pool) + .await?; + } + _ => { + crate::state::clear_shared_instance(instance_id, &state.pool) + .await?; + } + } + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(()) +} + +pub(super) fn shared_instance_unavailable_error( + reason: SharedInstanceUnavailableReason, +) -> crate::Error { + crate::ErrorKind::SharedInstanceUnavailable(reason).into() +} + +pub(super) fn shared_instance_unavailable_message( + reason: SharedInstanceUnavailableReason, +) -> &'static str { + match reason { + SharedInstanceUnavailableReason::Deleted => { + "Shared instance was deleted" + } + SharedInstanceUnavailableReason::AccessRevoked => { + "Shared instance access was revoked" + } + SharedInstanceUnavailableReason::Quarantined => { + "Shared instance was quarantined" + } + } +} + +fn shared_instance_external_file_data( + file: ExternalFileResponse, +) -> crate::Result { + let file_size = file.file_size.ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {} is missing its size", + file.file_name + )) + })?; + let file_size = u64::try_from(file_size).map_err(|_| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {} has an invalid size", + file.file_name + )) + })?; + + Ok(SharedInstanceExternalFileData { + file_name: file.file_name, + file_type: file.file_type, + url: file.url, + file_size, + }) +} + +pub(super) async fn shared_instance_install_data( + shared_instance_id: &str, + manager_id: Option, + server_manager_name: Option, + server_manager_icon_url: Option, + instance_icon_url: Option, + name: String, + version: InstanceVersionResponse, + state: &State, +) -> crate::Result { + if !version.ready { + return Err(crate::ErrorKind::InputError( + "Shared instance version is not ready to install".to_string(), + ) + .into()); + } + + let name = shared_instance_name(name); + let linked_user_id = linked_modrinth_user_id(state).await?; + let modpack = shared_instance_install_modpack(&version, state).await?; + let modpack_version_id = + modpack.as_ref().map(|modpack| modpack.version_id.as_str()); + let modrinth_ids = version + .modrinth_ids + .into_iter() + .filter(|id| Some(id.as_str()) != modpack_version_id) + .collect(); + + Ok(SharedInstanceInstallData { + shared_instance_id: shared_instance_id.to_string(), + manager_id, + server_manager_name, + server_manager_icon_url, + instance_icon_url, + linked_user_id, + name, + version: version.version, + modrinth_ids, + external_files: version + .external_files + .into_iter() + .map(shared_instance_external_file_data) + .collect::>>()?, + modpack, + game_version: version.game_version, + loader: version.loader, + loader_version: shared_instance_loader_version(version.loader_version), + }) +} + +pub(super) async fn linked_modrinth_user_id( + state: &State, +) -> crate::Result> { + Ok( + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + .map(|credentials| credentials.user_id), + ) +} diff --git a/packages/app-lib/src/api/instance/shared/invites.rs b/packages/app-lib/src/api/instance/shared/invites.rs new file mode 100644 index 000000000..d70f68901 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/invites.rs @@ -0,0 +1,184 @@ +const SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS: i32 = 604800; +const SHARED_INSTANCE_INVITE_MAX_USES: i32 = 10; + +use super::client::*; +use super::install::*; +use super::publish::*; +use super::types::*; +use super::*; + +#[tracing::instrument] +pub async fn get_shared_instance_users( + instance_id: &str, +) -> crate::Result { + let state = State::get().await?; + let Some(attachment) = shared_attachment(instance_id, &state).await? else { + return Ok(SharedInstanceUsers::empty()); + }; + + get_remote_users(&attachment.id, &state).await +} + +#[tracing::instrument] +pub async fn invite_shared_instance_users( + instance_id: &str, + user_ids: Vec, +) -> crate::Result { + let state = State::get().await?; + let (metadata, attachment) = + shared_instance_for_invites(instance_id, user_ids.len(), &state) + .await?; + + ensure_owner(&attachment)?; + if !user_ids.is_empty() { + ensure_ready_remote_version_for_invite( + instance_id, + &attachment, + &state, + ) + .await?; + update_remote_instance( + &attachment.id, + shared_instance_name(metadata.instance.name.clone()), + &state, + ) + .await?; + tracing::info!( + instance_id, + shared_instance_id = %attachment.id, + user_count = user_ids.len(), + "Adding users to shared instance" + ); + add_remote_users(&attachment.id, user_ids.clone(), &state).await?; + } + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + get_remote_users(&attachment.id, &state).await +} + +#[tracing::instrument(skip(replace_invite_id))] +pub async fn create_shared_instance_invite_link( + instance_id: &str, + max_age_seconds: Option, + max_uses: Option, + replace_invite_id: Option, +) -> crate::Result { + let state = State::get().await?; + let (metadata, attachment) = + shared_instance_for_invites(instance_id, 0, &state).await?; + ensure_owner(&attachment)?; + ensure_ready_remote_version_for_invite(instance_id, &attachment, &state) + .await?; + update_remote_instance( + &attachment.id, + shared_instance_name(metadata.instance.name), + &state, + ) + .await?; + + let max_age_seconds = + max_age_seconds.unwrap_or(SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS); + let max_uses = max_uses.unwrap_or(SHARED_INSTANCE_INVITE_MAX_USES); + if max_age_seconds <= 0 + || max_age_seconds > SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS + { + return Err(crate::ErrorKind::InputError( + "Invite expiry must be between now and seven days".to_string(), + ) + .into()); + } + if max_uses <= 0 { + return Err(crate::ErrorKind::InputError( + "Invite max uses must be greater than zero".to_string(), + ) + .into()); + } + + if let Some(invite_id) = replace_invite_id { + delete_remote_invite(&attachment.id, &invite_id, &state).await?; + } + + let created_at = Utc::now(); + + let response = request_json::( + "create_instance_invite", + Method::POST, + &format!("/instances/{}/invites", attachment.id), + Some(json!({ + "max_age": max_age_seconds, + "max_uses": max_uses, + })), + &state, + ) + .await?; + + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(SharedInstanceInviteLink { + invite_id: response.id, + expires_at: created_at + + chrono::Duration::seconds(i64::from(max_age_seconds)), + max_uses, + }) +} + +#[tracing::instrument] +pub async fn remove_shared_instance_users( + instance_id: &str, + user_ids: Vec, + has_pending_recipients: bool, +) -> crate::Result { + let state = State::get().await?; + let Some(attachment) = shared_attachment(instance_id, &state).await? else { + return Ok(SharedInstanceUsers::empty()); + }; + ensure_owner(&attachment)?; + + if !user_ids.is_empty() { + remove_remote_users(&attachment.id, user_ids, &state).await?; + } + + let remaining_users = get_remote_users(&attachment.id, &state).await?; + if !has_shared_instance_recipients( + &remaining_users, + &attachment, + has_pending_recipients, + &state, + ) + .await? + { + delete_remote_instance(&attachment.id, &state).await?; + crate::state::clear_shared_instance(instance_id, &state.pool).await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + return Ok(SharedInstanceUsers::empty()); + } + + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(remaining_users) +} + +#[tracing::instrument] +pub async fn accept_pending_shared_instance_invite( + shared_instance_id: &str, +) -> crate::Result<()> { + let state = State::get().await?; + + if accept_pending_remote_invite(shared_instance_id, &state).await? { + return Ok(()); + } + + Err(crate::ErrorKind::InputError( + "No pending invite found for shared instance".to_string(), + ) + .into()) +} + +#[tracing::instrument] +pub async fn decline_pending_shared_instance_invite( + shared_instance_id: &str, +) -> crate::Result<()> { + let state = State::get().await?; + decline_pending_remote_invite(shared_instance_id, &state).await +} diff --git a/packages/app-lib/src/api/instance/shared/mod.rs b/packages/app-lib/src/api/instance/shared/mod.rs new file mode 100644 index 000000000..5ebb1a13d --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/mod.rs @@ -0,0 +1,140 @@ +use super::content_set_diff::{ + ContentSetDiffEntry, ContentSetDiffKind, ContentSetDiffOptions, + ContentSetSnapshot, ContentSetSnapshotVersion, diff_content_sets, +}; +use crate::SharedInstanceUnavailableReason; +use crate::event::InstancePayloadType; +use crate::event::emit::emit_instance; +use crate::install::{ + InstallJobSnapshot, SharedInstanceExternalFileData, + SharedInstanceInstallData, +}; +use crate::state::instances::{InstanceLink, SharedInstanceAttachment}; +use crate::state::{ + AppliedContentSetPatch, CacheBehaviour, CachedEntry, ContentSetSyncStatus, + ContentSourceKind, EditInstance, ModLoader, ModrinthCredentials, + ProjectType, SharedInstanceRole, State, +}; +use crate::util::fetch::{INSECURE_REQWEST_CLIENT, REQWEST_CLIENT}; +use chrono::{DateTime, Utc}; +use reqwest::{Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::io::Read; + +pub(crate) const CONFIG_BUNDLE_FILE_NAME: &str = "configs.zip"; +pub(crate) const CONFIG_BUNDLE_FILE_TYPE: &str = "configs"; +pub(crate) const CONFIG_SYNC_ENABLED: bool = true; +pub(crate) const CONFIG_DIRECTORY: &str = "config"; +pub(crate) const MAX_CONFIG_BUNDLE_ENTRIES: usize = 4096; +pub(crate) const MAX_CONFIG_BUNDLE_FILE_SIZE: u64 = 16 * 1024 * 1024; +pub(crate) const MAX_CONFIG_BUNDLE_TOTAL_SIZE: u64 = 128 * 1024 * 1024; +pub(crate) const CONFIG_FILE_EXTENSIONS: [&str; 13] = [ + "json", + "json5", + "jsonc", + "yml", + "yaml", + "css", + "toml", + "txt", + "ini", + "cfg", + "conf", + "properties", + "xml", +]; + +pub(crate) fn read_bounded_config_bundle_entry( + reader: impl Read, + declared_size: u64, + total_size: &mut u64, +) -> crate::Result> { + let remaining = MAX_CONFIG_BUNDLE_TOTAL_SIZE.saturating_sub(*total_size); + let entry_limit = MAX_CONFIG_BUNDLE_FILE_SIZE.min(remaining); + if declared_size > entry_limit { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle exceeds the uncompressed size limit" + .to_string(), + ) + .into()); + } + + let capacity = usize::try_from(declared_size).map_err(|_| { + crate::ErrorKind::InputError( + "Shared instance config bundle entry is too large".to_string(), + ) + })?; + let mut bytes = Vec::with_capacity(capacity); + reader + .take(entry_limit.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Failed to read shared instance config bundle: {error}" + )) + })?; + let actual_size = bytes.len() as u64; + if actual_size > entry_limit { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle exceeds the uncompressed size limit" + .to_string(), + ) + .into()); + } + *total_size = total_size.checked_add(actual_size).ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance config bundle size overflowed".to_string(), + ) + })?; + + Ok(bytes) +} + +mod client; +mod diff; +mod install; +mod invites; +mod publish; +mod types; + +pub(crate) use self::install::check_shared_instance_availability_before_launch; +pub(crate) use self::publish::sync_shared_instance_icon; + +pub use self::install::{ + accept_shared_instance_invite_for_install, + get_shared_instance_install_preview, get_shared_instance_update_preview, + install_shared_instance, update_shared_instance, +}; +pub use self::invites::{ + accept_pending_shared_instance_invite, create_shared_instance_invite_link, + decline_pending_shared_instance_invite, get_shared_instance_users, + invite_shared_instance_users, remove_shared_instance_users, +}; +pub use self::publish::{ + get_shared_instance_publish_preview, publish_shared_instance, + unlink_shared_instance, unpublish_shared_instance, +}; +pub use self::types::{ + SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, + SharedInstanceInviteInstallPreview, SharedInstanceInviteLink, + SharedInstanceJoinType, SharedInstancePublishPreview, + SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, + SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, +}; + +pub async fn can_active_user_use_shared_instances() -> crate::Result { + let state = State::get().await?; + let Some(credentials) = + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + else { + return Ok(true); + }; + + let status = + client::get_user_blacklist_status(&credentials.user_id, &state).await?; + Ok(!status.blacklisted) +} diff --git a/packages/app-lib/src/api/instance/shared/publish.rs b/packages/app-lib/src/api/instance/shared/publish.rs new file mode 100644 index 000000000..b3af7666b --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/publish.rs @@ -0,0 +1,1163 @@ +use super::client::*; +use super::diff::*; +use super::install::*; +use super::types::*; +use super::*; +use async_walkdir::WalkDir; +use async_zip::{Compression, ZipEntryBuilder}; +use futures::StreamExt; +use std::collections::BTreeMap; + +#[tracing::instrument] +pub async fn unpublish_shared_instance(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance.clone() else { + return Ok(()); + }; + ensure_owner(&attachment)?; + + delete_remote_instance(&attachment.id, &state).await?; + detach_local_shared_instance(instance_id, metadata.link, &state).await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(()) +} + +#[tracing::instrument] +pub async fn unlink_shared_instance(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance.clone() else { + return Ok(()); + }; + ensure_member(&attachment)?; + + detach_local_shared_instance(instance_id, metadata.link, &state).await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(()) +} + +pub(super) async fn detach_local_shared_instance( + instance_id: &str, + link: InstanceLink, + state: &State, +) -> crate::Result<()> { + let link_patch = match link { + InstanceLink::SharedInstance { + modpack_project_id: Some(project_id), + modpack_version_id: Some(version_id), + } => Some(( + InstanceLink::ModrinthModpack { + project_id, + version_id, + }, + ContentSourceKind::ModrinthModpack, + )), + InstanceLink::SharedInstance { .. } => { + Some((InstanceLink::Unmanaged, ContentSourceKind::Local)) + } + _ => None, + }; + + if let Some((link, source_kind)) = link_patch { + crate::state::edit_instance( + instance_id, + EditInstance { + link: Some(link), + content_set_patch: Some(AppliedContentSetPatch { + source_kind: Some(source_kind), + ..Default::default() + }), + ..Default::default() + }, + &state.pool, + ) + .await?; + } + + crate::state::clear_shared_instance(instance_id, &state.pool).await?; + + Ok(()) +} + +#[tracing::instrument] +pub async fn publish_shared_instance( + instance_id: &str, + config_paths: Vec, +) -> crate::Result { + let state = State::get().await?; + publish_shared_instance_inner(instance_id, &config_paths, &state).await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + shared_attachment(instance_id, &state) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance attachment was not persisted".to_string(), + ) + .into() + }) +} + +#[tracing::instrument] +pub async fn get_shared_instance_publish_preview( + instance_id: &str, +) -> crate::Result> { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(attachment) = metadata.shared_instance.clone() else { + return Ok(None); + }; + ensure_owner(&attachment)?; + + let (version, snapshot) = tokio::try_join!( + get_latest_remote_version_optional_unavailable(&attachment.id, &state,), + collect_publish_snapshot(&metadata, &state), + )?; + let version = match version { + SharedInstanceRemoteResponse::Available(version) => version, + SharedInstanceRemoteResponse::Unavailable(reason) => { + handle_unavailable_shared_instance_if_current_user( + instance_id, + &attachment, + reason, + &state, + ) + .await?; + return Err(shared_instance_unavailable_error(reason)); + } + }; + let diffs = + shared_instance_publish_diffs(&metadata, &version, &snapshot, &state) + .await?; + set_shared_instance_publish_status( + instance_id, + &attachment, + version.version, + !diffs.is_empty(), + &state, + ) + .await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(Some(SharedInstancePublishPreview { + shared_instance_id: attachment.id, + latest_version: version.version, + diffs, + config_files: snapshot + .config_files + .into_iter() + .map(|file| file.path) + .collect(), + })) +} + +pub(super) async fn remote_publish_content( + version: &InstanceVersionResponse, + include_modpack_dependencies: bool, + state: &State, +) -> crate::Result<(Vec, HashSet)> { + let mut version_ids = version.modrinth_ids.clone(); + if let Some(modpack_id) = + version.modpack_id.as_deref().filter(|id| !id.is_empty()) + { + version_ids.retain(|id| id != modpack_id); + + if include_modpack_dependencies { + version_ids.extend( + modpack_dependency_version_ids(modpack_id, state).await?, + ); + } + } + dedupe_strings(&mut version_ids); + + Ok(( + version_ids, + version + .external_files + .iter() + .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .map(|file| file.file_name.clone()) + .collect(), + )) +} + +pub(super) async fn modpack_dependency_version_ids( + modpack_id: &str, + state: &State, +) -> crate::Result> { + let modpack_version = CachedEntry::get_version( + modpack_id, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance modpack version was not found".to_string(), + ) + })?; + + Ok(modpack_version + .dependencies + .into_iter() + .filter_map(|dependency| dependency.version_id) + .collect()) +} + +pub(super) async fn shared_instance_install_modpack( + version: &InstanceVersionResponse, + state: &State, +) -> crate::Result> { + let Some(modpack_id) = + version.modpack_id.as_deref().filter(|id| !id.is_empty()) + else { + return Ok(None); + }; + let modpack_version = CachedEntry::get_version( + modpack_id, + Some(CacheBehaviour::MustRevalidate), + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance modpack version was not found".to_string(), + ) + })?; + let project = CachedEntry::get_project( + &modpack_version.project_id, + Some(CacheBehaviour::MustRevalidate), + &state.pool, + &state.api_semaphore, + ) + .await?; + + Ok(Some(crate::install::SharedInstanceInstallModpack { + project_id: modpack_version.project_id, + version_id: modpack_version.id, + dependency_count: modpack_version.dependencies.len(), + title: project + .as_ref() + .map(|project| project.title.clone()) + .unwrap_or(modpack_version.name), + icon_url: project.and_then(|project| project.icon_url), + })) +} + +pub(super) fn shared_instance_loader_version( + loader_version: String, +) -> Option { + (!loader_version.is_empty()).then_some(loader_version) +} + +pub(super) async fn current_shared_content( + metadata: &crate::state::InstanceMetadata, + include_linked_modpack_content: bool, + state: &State, +) -> crate::Result<(Vec, HashSet)> { + let entries = + crate::state::instances::adapters::sqlite::content_rows::get_content_entries( + &metadata.applied_content_set.id, + &state.pool, + ) + .await?; + let files = crate::state::instances::adapters::sqlite::content_rows::get_instance_files( + &metadata.instance.id, + &state.pool, + ) + .await? + .into_iter() + .map(|file| (file.id.clone(), file)) + .collect::>(); + let mut version_ids = Vec::new(); + let mut external_files = HashSet::new(); + + for entry in entries { + let include_entry = entry.source_kind + == crate::state::ContentSourceKind::SharedInstance + || (include_linked_modpack_content + && entry.source_kind + == crate::state::ContentSourceKind::ModrinthModpack); + if !include_entry { + continue; + } + + if let Some(version_id) = entry.version_id { + version_ids.push(version_id); + continue; + } + + let Some(file_id) = entry.file_id else { + continue; + }; + if let Some(file) = files.get(&file_id) { + external_files.insert(file.file_name.clone()); + } + } + if include_linked_modpack_content + && let Some(modpack_id) = shared_modpack_id(&metadata.link) + { + version_ids + .extend(modpack_dependency_version_ids(&modpack_id, state).await?); + } + dedupe_strings(&mut version_ids); + + Ok((version_ids, external_files)) +} + +pub(super) struct CurrentPublishSnapshot { + pub(super) version_ids: Vec, + pub(super) external_files: Vec, + pub(super) disabled_project_ids: HashSet, + pub(super) disabled_version_ids: Vec, + pub(super) disabled_external_files: HashSet, + pub(super) config_files: Vec, +} + +pub(super) async fn collect_publish_snapshot( + metadata: &crate::state::InstanceMetadata, + state: &State, +) -> crate::Result { + let instance_path = state + .directories + .instances_dir() + .join(&metadata.instance.path); + let (items, config_files) = if CONFIG_SYNC_ENABLED { + tokio::try_join!( + crate::state::list_content( + &metadata.instance.id, + None, + None, + state, + ), + collect_config_files(&instance_path), + )? + } else { + ( + crate::state::list_content( + &metadata.instance.id, + None, + None, + state, + ) + .await?, + Vec::new(), + ) + }; + let modpack_id = shared_modpack_id(&metadata.link); + let mut version_ids = Vec::new(); + let mut seen_version_ids = HashSet::new(); + let mut external_files = Vec::new(); + let mut seen_external_files = HashSet::new(); + let mut disabled_project_ids = HashSet::new(); + let mut disabled_version_ids = Vec::new(); + let mut seen_disabled_version_ids = HashSet::new(); + let mut disabled_external_files = HashSet::new(); + + for item in items { + if item.enabled { + if let Some(version) = item.version { + if seen_version_ids.insert(version.id.clone()) { + version_ids.push(version.id); + } + continue; + } + + if item.file_path.is_empty() { + continue; + } + + let file_type = file_type(item.project_type); + let external_key = format!("{}:{file_type}", item.file_path); + if seen_external_files.insert(external_key) { + external_files.push(ExternalFileCandidate { + file_name: item.file_name, + file_type, + source: ExternalFileSource::InstanceFile(item.file_path), + }); + } + continue; + } + + let is_modpack = item.version.as_ref().is_some_and(|version| { + modpack_id.as_deref() == Some(version.id.as_str()) + }); + if is_modpack { + continue; + } + + if let Some(project) = item.project.as_ref() { + disabled_project_ids.insert(project.id.clone()); + } + + if let Some(version) = item.version { + if seen_disabled_version_ids.insert(version.id.clone()) { + disabled_version_ids.push(version.id); + } + continue; + } + + if item.file_path.is_empty() { + continue; + } + + disabled_external_files.insert(enabled_file_name(&item.file_name)); + } + + Ok(CurrentPublishSnapshot { + version_ids, + external_files, + disabled_project_ids, + disabled_version_ids, + disabled_external_files, + config_files, + }) +} + +pub(super) async fn shared_versions_by_project( + version_ids: &[String], + state: &State, +) -> crate::Result> { + let version_id_refs = + version_ids.iter().map(String::as_str).collect::>(); + let versions = CachedEntry::get_version_many( + &version_id_refs, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await?; + + Ok(versions + .into_iter() + .map(|version| (version.project_id.clone(), version)) + .collect()) +} + +pub(super) async fn shared_project_names( + project_ids: &HashSet, + state: &State, +) -> crate::Result> { + let project_id_refs = + project_ids.iter().map(String::as_str).collect::>(); + let projects = CachedEntry::get_project_many( + &project_id_refs, + Some(CacheBehaviour::Bypass), + &state.pool, + &state.api_semaphore, + ) + .await?; + + Ok(projects + .into_iter() + .map(|project| (project.id, project.title)) + .collect()) +} + +pub(super) fn dedupe_strings(values: &mut Vec) { + let mut seen = HashSet::new(); + values.retain(|value| seen.insert(value.clone())); +} + +pub(super) fn enabled_file_name(file_name: &str) -> String { + file_name + .strip_suffix(".disabled") + .unwrap_or(file_name) + .to_string() +} + +pub(super) fn shared_instance_name(name: String) -> String { + match name.trim() { + "" => "Shared instance".to_string(), + name => name.to_string(), + } +} + +pub(super) async fn set_shared_instance_publish_status( + instance_id: &str, + attachment: &SharedInstanceAttachment, + latest_version: i32, + has_changes: bool, + state: &State, +) -> crate::Result<()> { + let (status, applied_version) = if has_changes { + (ContentSetSyncStatus::Stale, attachment.applied_version) + } else { + (ContentSetSyncStatus::UpToDate, Some(latest_version)) + }; + + crate::state::set_shared_instance_sync_status( + instance_id, + status, + applied_version, + Some(latest_version), + &state.pool, + ) + .await +} + +pub(super) async fn publish_shared_instance_inner( + instance_id: &str, + config_paths: &[String], + state: &State, +) -> crate::Result<()> { + let attachment = + shared_attachment(instance_id, state) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Instance is not attached to a shared instance".to_string(), + ) + })?; + ensure_owner(&attachment)?; + tracing::info!( + instance_id, + shared_instance_id = %attachment.id, + applied_version = attachment.applied_version, + latest_version = attachment.latest_version, + "Publishing shared instance content" + ); + + crate::state::set_shared_instance_sync_status( + instance_id, + ContentSetSyncStatus::Applying, + attachment.applied_version, + attachment.latest_version, + &state.pool, + ) + .await?; + + let result = publish_current_content( + instance_id, + &attachment.id, + config_paths, + state, + ) + .await; + + match result { + Ok(version) => { + tracing::info!( + instance_id, + shared_instance_id = %attachment.id, + version, + "Published shared instance content" + ); + crate::state::set_shared_instance_sync_status( + instance_id, + ContentSetSyncStatus::UpToDate, + Some(version), + Some(version), + &state.pool, + ) + .await?; + Ok(()) + } + Err(error) => { + tracing::warn!( + instance_id, + shared_instance_id = %attachment.id, + error = %error, + "Failed to publish shared instance content" + ); + crate::state::set_shared_instance_sync_status( + instance_id, + ContentSetSyncStatus::Error, + attachment.applied_version, + attachment.latest_version, + &state.pool, + ) + .await?; + Err(error) + } + } +} + +pub(super) async fn publish_current_content( + instance_id: &str, + shared_instance_id: &str, + config_paths: &[String], + state: &State, +) -> crate::Result { + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + ensure_shareable_link(&metadata.link)?; + update_remote_instance( + shared_instance_id, + shared_instance_name(metadata.instance.name.clone()), + state, + ) + .await?; + let modpack_id = shared_modpack_id(&metadata.link); + let snapshot = collect_publish_snapshot(&metadata, state).await?; + let previous_version = if CONFIG_SYNC_ENABLED + && metadata + .shared_instance + .as_ref() + .and_then(|attachment| attachment.applied_version) + .is_some() + { + match get_latest_remote_version_optional_unavailable( + shared_instance_id, + state, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => Some(version), + SharedInstanceRemoteResponse::Unavailable(_) => None, + } + } else { + None + }; + let config_bundle = if CONFIG_SYNC_ENABLED { + build_config_bundle_candidate( + &metadata.instance.path, + &snapshot.config_files, + config_paths, + previous_version.as_ref(), + state, + ) + .await? + } else { + None + }; + let modrinth_ids = snapshot.version_ids; + let mut external_files = snapshot.external_files; + if let Some(config_bundle) = config_bundle { + external_files.push(config_bundle); + } + tracing::debug!( + instance_id, + shared_instance_id, + modpack_id = modpack_id.as_deref().unwrap_or("none"), + modrinth_id_count = modrinth_ids.len(), + external_file_count = external_files.len(), + "Creating shared instance version" + ); + let external_file_data = external_files + .iter() + .map(|file| ExternalFileData { + file_name: file.file_name.clone(), + file_type: file.file_type.clone(), + }) + .collect::>(); + let response = request_json_optional_unavailable::( + "create_instance_version", + Method::POST, + &format!("/instances/{shared_instance_id}/versions"), + Some(json!({ + "modrinth_ids": modrinth_ids, + "external_files": external_file_data, + "modpack_id": modpack_id, + "game_version": metadata.applied_content_set.game_version.clone(), + "loader": metadata.applied_content_set.loader.as_str(), + "loader_version": metadata + .applied_content_set + .loader_version + .clone() + .unwrap_or_default(), + })), + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await?; + let response = match response { + SharedInstanceRemoteResponse::Available(response) => response, + SharedInstanceRemoteResponse::Unavailable(reason) => { + if let Some(attachment) = metadata.shared_instance.as_ref() { + handle_unavailable_shared_instance_if_current_user( + instance_id, + attachment, + reason, + state, + ) + .await?; + } + + return Err(shared_instance_unavailable_error(reason)); + } + }; + + if !response.external_files.is_empty() { + upload_external_files( + &metadata.instance.path, + &external_files, + &response.external_files, + state, + ) + .await?; + } else if !response.ready { + tracing::debug!( + "Shared instance version {} was not ready but had no external files", + response.version + ); + } + + Ok(response.version) +} + +pub(super) async fn collect_config_files( + instance_path: &std::path::Path, +) -> crate::Result> { + let config_path = instance_path.join(CONFIG_DIRECTORY); + crate::util::io::create_dir_all(&config_path).await?; + let mut files = Vec::new(); + let mut walker = WalkDir::new(&config_path); + + while let Some(entry) = walker.next().await { + let entry = entry.map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Failed to read config directory: {error}" + )) + })?; + if !entry.file_type().await?.is_file() + || !is_supported_config_file(&entry.path()) + { + continue; + } + + let entry_path = entry.path(); + let relative_path = entry_path.strip_prefix(&config_path)?; + let path = relative_path.to_string_lossy().replace('\\', "/"); + files.push(ConfigFile { path }); + } + + files.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(files) +} + +fn is_supported_config_file(path: &std::path::Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + CONFIG_FILE_EXTENSIONS + .iter() + .any(|candidate| extension.eq_ignore_ascii_case(candidate)) + }) +} + +async fn build_config_bundle_candidate( + instance_path: &str, + local_files: &[ConfigFile], + selected_paths: &[String], + previous_version: Option<&InstanceVersionResponse>, + state: &State, +) -> crate::Result> { + let selected_paths = selected_paths + .iter() + .map(String::as_str) + .collect::>(); + let local_files_by_path = local_files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect::>(); + for selected_path in &selected_paths { + if !local_files_by_path.contains_key(selected_path) { + return Err(crate::ErrorKind::InputError(format!( + "Config file is unavailable for sharing: {selected_path}" + )) + .into()); + } + } + + let previous_bundle = previous_version.and_then(|version| { + version + .external_files + .iter() + .find(|file| file.file_type == CONFIG_BUNDLE_FILE_TYPE) + }); + if previous_bundle.is_none() && selected_paths.is_empty() { + return Ok(None); + } + + let mut entries = BTreeMap::new(); + if let (Some(_), Some(previous_bundle)) = + (previous_version, previous_bundle) + { + let response = REQWEST_CLIENT.get(&previous_bundle.url).send().await?; + if !response.status().is_success() { + return Err(crate::ErrorKind::OtherError(format!( + "Previous config bundle download failed with status {}", + response.status() + )) + .into()); + } + let bytes = response.bytes().await?; + let archived_entries = tokio::task::spawn_blocking(move || { + read_config_bundle(bytes.as_ref()) + }) + .await??; + entries.extend(archived_entries); + } + + let config_path = state + .directories + .instances_dir() + .join(instance_path) + .join(CONFIG_DIRECTORY); + for selected_path in selected_paths { + let file = local_files_by_path + .get(selected_path) + .expect("selected config paths were validated"); + let bytes = crate::util::io::read(config_path.join(&file.path)).await?; + entries.insert(file.path.clone(), bytes); + } + + let bytes = config_bundle_bytes(&entries).await?; + + Ok(Some(ExternalFileCandidate { + file_name: CONFIG_BUNDLE_FILE_NAME.to_string(), + file_type: CONFIG_BUNDLE_FILE_TYPE.to_string(), + source: ExternalFileSource::ConfigBundle(bytes), + })) +} + +async fn config_bundle_bytes( + entries: &BTreeMap>, +) -> crate::Result> { + if entries.len() > MAX_CONFIG_BUNDLE_ENTRIES { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle contains too many entries" + .to_string(), + ) + .into()); + } + let mut total_size = 0_u64; + for bytes in entries.values() { + let size = bytes.len() as u64; + if size > MAX_CONFIG_BUNDLE_FILE_SIZE { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle contains a file that is too large" + .to_string(), + ) + .into()); + } + total_size = total_size.checked_add(size).ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance config bundle size overflowed".to_string(), + ) + })?; + if total_size > MAX_CONFIG_BUNDLE_TOTAL_SIZE { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle exceeds the uncompressed size limit" + .to_string(), + ) + .into()); + } + } + + let mut writer = async_zip::base::write::ZipFileWriter::new(Vec::new()); + for (path, bytes) in entries { + writer + .write_entry_whole( + ZipEntryBuilder::new(path.clone().into(), Compression::Deflate), + bytes, + ) + .await?; + } + + Ok(writer.close().await?) +} + +fn read_config_bundle( + bytes: &[u8], +) -> crate::Result>> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|error| { + crate::ErrorKind::InputError(format!( + "Invalid shared instance config bundle: {error}" + )) + })?; + if archive.len() > MAX_CONFIG_BUNDLE_ENTRIES { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle contains too many entries" + .to_string(), + ) + .into()); + } + let mut entries = BTreeMap::new(); + let mut total_size = 0; + + for index in 0..archive.len() { + let file = archive.by_index(index).map_err(|error| { + crate::ErrorKind::InputError(format!( + "Invalid shared instance config bundle entry: {error}" + )) + })?; + if file.is_dir() { + continue; + } + let path = file.enclosed_name().ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance config bundle contains an unsafe path" + .to_string(), + ) + })?; + if !is_supported_config_file(&path) { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance config bundle contains unsupported file {}", + path.display() + )) + .into()); + } + let path = path.to_string_lossy().replace('\\', "/"); + let declared_size = file.size(); + let bytes = read_bounded_config_bundle_entry( + file, + declared_size, + &mut total_size, + )?; + if entries.insert(path.clone(), bytes).is_some() { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance config bundle contains duplicate file {path}" + )) + .into()); + } + } + + Ok(entries) +} + +pub(super) fn shared_modpack_id(link: &InstanceLink) -> Option { + match link { + InstanceLink::ModrinthModpack { version_id, .. } => { + Some(version_id.clone()) + } + InstanceLink::ServerProjectModpack { + content_version_id, .. + } => Some(content_version_id.clone()), + InstanceLink::SharedInstance { + modpack_version_id: Some(version_id), + .. + } => Some(version_id.clone()), + _ => None, + } +} + +pub(super) fn ensure_shareable_link(link: &InstanceLink) -> crate::Result<()> { + if matches!(link, InstanceLink::ImportedModpack { .. }) { + return Err(crate::ErrorKind::InputError( + "You must unlink this modpack to share your instance".to_string(), + ) + .into()); + } + + Ok(()) +} + +pub(super) async fn upload_external_files( + instance_path: &str, + candidates: &[ExternalFileCandidate], + uploads: &[ExternalFileResponse], + state: &State, +) -> crate::Result<()> { + for upload in uploads { + let candidate = candidates + .iter() + .find(|candidate| { + candidate.file_name == upload.file_name + && candidate.file_type == upload.file_type + }) + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Shared instance service requested unknown external file {}", + upload.file_name + )) + })?; + let bytes = match &candidate.source { + ExternalFileSource::InstanceFile(file_path) => { + let path = state + .directories + .instances_dir() + .join(instance_path) + .join(file_path); + crate::util::io::read(path).await? + } + ExternalFileSource::ConfigBundle(bytes) => bytes.clone(), + }; + let upload_url = url::Url::parse(&upload.url).map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Invalid shared instance external file upload URL: {error}" + )) + })?; + let response = send_bytes_request_to_url( + "upload_external_file", + Method::PUT, + upload_url.path(), + &upload.url, + bytes, + state, + ) + .await?; + + if !response.status().is_success() { + return shared_instances_request_error( + "upload_external_file", + Method::PUT, + upload_url.path(), + response, + ) + .await; + } + } + + Ok(()) +} + +pub(super) async fn shared_attachment( + instance_id: &str, + state: &State, +) -> crate::Result> { + Ok(crate::state::get_instance(instance_id, &state.pool) + .await? + .and_then(|metadata| metadata.shared_instance)) +} + +pub(crate) async fn sync_shared_instance_icon( + instance_id: &str, + icon_path: Option<&str>, + state: &State, +) -> crate::Result<()> { + let Some(attachment) = shared_attachment(instance_id, state).await? else { + return Ok(()); + }; + if attachment.role != SharedInstanceRole::Owner { + return Ok(()); + } + + update_remote_instance_icon(&attachment.id, icon_path, state).await +} + +pub(super) async fn shared_instance_for_invites( + instance_id: &str, + user_count: usize, + state: &State, +) -> crate::Result<(crate::state::InstanceMetadata, SharedInstanceAttachment)> { + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let attachment = match metadata.shared_instance.clone() { + Some(attachment) => { + tracing::debug!( + instance_id, + shared_instance_id = %attachment.id, + role = attachment.role.as_str(), + user_count, + "Using existing shared instance attachment for invite" + ); + attachment + } + None => { + ensure_shareable_link(&metadata.link)?; + tracing::info!( + instance_id, + user_count, + "Creating shared instance before first invite" + ); + let remote = create_remote_instance( + shared_instance_name(metadata.instance.name.clone()), + state, + ) + .await?; + let linked_user_id = linked_modrinth_user_id(state).await?; + tracing::info!( + instance_id, + shared_instance_id = %remote.id, + "Created remote shared instance" + ); + crate::state::attach_shared_instance( + instance_id, + crate::state::SharedInstanceAttachmentInput { + id: remote.id.clone(), + role: SharedInstanceRole::Owner, + manager_id: None, + server_manager_name: None, + server_manager_icon_url: None, + linked_user_id, + status: ContentSetSyncStatus::Unknown, + applied_version: None, + latest_version: None, + }, + &state.pool, + ) + .await?; + tracing::debug!( + instance_id, + shared_instance_id = %remote.id, + "Attached local instance as shared instance owner" + ); + publish_shared_instance_inner(instance_id, &[], state).await?; + shared_attachment(instance_id, state) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance attachment was not persisted" + .to_string(), + ) + })? + } + }; + + ensure_owner(&attachment)?; + update_remote_instance_icon( + &attachment.id, + metadata.instance.icon_path.as_deref(), + state, + ) + .await?; + + Ok((metadata, attachment)) +} + +pub(super) fn ensure_owner( + attachment: &SharedInstanceAttachment, +) -> crate::Result<()> { + if attachment.role == SharedInstanceRole::Owner { + return Ok(()); + } + + Err(crate::ErrorKind::InputError( + "Only the owner instance can manage shared instance users".to_string(), + ) + .into()) +} + +pub(super) fn ensure_member( + attachment: &SharedInstanceAttachment, +) -> crate::Result<()> { + if attachment.role.is_member() { + return Ok(()); + } + + Err(crate::ErrorKind::InputError( + "Only shared instance members can unlink from shared instances" + .to_string(), + ) + .into()) +} + +pub(super) fn file_type(project_type: ProjectType) -> String { + project_type.get_name().to_string() +} diff --git a/packages/app-lib/src/api/instance/shared/types.rs b/packages/app-lib/src/api/instance/shared/types.rs new file mode 100644 index 000000000..05a98d788 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/types.rs @@ -0,0 +1,162 @@ +use super::*; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SharedInstanceUsers { + pub user_ids: Vec, + #[serde(default)] + pub users: Vec, + #[serde(default)] + pub tokens: i32, +} + +impl SharedInstanceUsers { + pub(super) fn empty() -> Self { + Self { + user_ids: Vec::new(), + users: Vec::new(), + tokens: 0, + } + } + + pub(super) fn from_users( + users: Vec, + tokens: i32, + ) -> Self { + let user_ids = users.iter().map(|user| user.id.clone()).collect(); + + Self { + user_ids, + users, + tokens, + } + } + + pub(super) fn from_user_ids(user_ids: Vec) -> Self { + let users = user_ids + .iter() + .map(|user_id| SharedInstanceUser { + id: user_id.clone(), + joined_at: None, + join_type: SharedInstanceJoinType::Invite, + last_played: None, + }) + .collect(); + + Self { + user_ids, + users, + tokens: 0, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SharedInstanceUser { + pub id: String, + pub joined_at: Option>, + pub join_type: SharedInstanceJoinType, + pub last_played: Option>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharedInstanceJoinType { + Owner, + Invite, + Link, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceInstallPreview { + pub shared_instance_id: String, + pub version: i32, + pub name: String, + pub icon_url: Option, + pub game_version: String, + pub loader: ModLoader, + pub mod_count: usize, + pub external_file_count: usize, + pub modpack_version_id: Option, + pub content_version_ids: Vec, + pub external_files: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceExternalFilePreview { + pub file_name: String, + pub file_type: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceUpdatePreview { + pub shared_instance_id: String, + pub current_version: Option, + pub latest_version: i32, + pub update_available: bool, + pub diffs: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstancePublishPreview { + pub shared_instance_id: String, + pub latest_version: i32, + pub diffs: Vec, + pub config_files: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceInviteLink { + pub invite_id: String, + pub expires_at: DateTime, + pub max_uses: i32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceInviteInstallPreview { + pub shared_instance_id: String, + pub manager_id: Option, + pub server_manager_name: Option, + pub server_manager_icon_url: Option, + pub instance_icon_url: Option, + pub preview: SharedInstanceInstallPreview, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceUpdateDiff { + #[serde(rename = "type")] + pub type_: SharedInstanceUpdateDiffType, + pub project_id: Option, + pub project_name: Option, + pub file_name: Option, + pub current_version_name: Option, + pub new_version_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_file_count: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub disabled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SharedInstanceUpdateDiffType { + Added, + Removed, + Updated, + ModpackLinked, + ModpackUpdated, + ModpackUnlinked, + GameVersionUpdated, + LoaderUpdated, + ConfigFilesUpdated, +} + +pub(super) fn is_false(value: &bool) -> bool { + !*value +} diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 927a666aa..96d5928ed 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -11,9 +11,11 @@ pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; +pub mod reports; pub mod server_address; pub mod settings; pub mod tags; +pub mod users; pub mod worlds; pub mod data { @@ -26,7 +28,8 @@ pub mod data { JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader, ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3, - Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize, + Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember, + Theme, User, UserFriend, Version, WindowSize, }; pub use ariadne::users::UserStatus; pub use modrinth_content_management::{ diff --git a/packages/app-lib/src/api/mr_auth.rs b/packages/app-lib/src/api/mr_auth.rs index 42ce41fe5..428a63a65 100644 --- a/packages/app-lib/src/api/mr_auth.rs +++ b/packages/app-lib/src/api/mr_auth.rs @@ -1,8 +1,19 @@ use crate::state::ModrinthCredentials; +use serde::Deserialize; + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ModrinthAuthFlow { + SignIn, + SignUp, +} #[tracing::instrument] -pub fn authenticate_begin_flow() -> &'static str { - crate::state::get_login_url() +pub fn authenticate_begin_flow(flow: ModrinthAuthFlow) -> &'static str { + match flow { + ModrinthAuthFlow::SignIn => crate::state::get_login_url(), + ModrinthAuthFlow::SignUp => crate::state::get_signup_url(), + } } #[tracing::instrument] diff --git a/packages/app-lib/src/api/reports.rs b/packages/app-lib/src/api/reports.rs new file mode 100644 index 000000000..75e13cb28 --- /dev/null +++ b/packages/app-lib/src/api/reports.rs @@ -0,0 +1,46 @@ +use crate::State; +use crate::util::fetch::fetch_json; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ReportItemType { + Project, + Version, + User, + SharedInstance, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CreateReportRequest { + pub report_type: String, + pub item_id: String, + pub item_type: ReportItemType, + pub body: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub uploaded_images: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CreateReportResponse { + pub id: String, +} + +#[tracing::instrument(skip(request))] +pub async fn create_report( + request: CreateReportRequest, +) -> crate::Result { + let state = State::get().await?; + + fetch_json( + Method::POST, + &format!("{}report", env!("MODRINTH_API_URL_V3")), + None, + Some(serde_json::to_value(request)?), + Some("/v3/report"), + &state.api_semaphore, + &state.pool, + ) + .await +} diff --git a/packages/app-lib/src/api/users.rs b/packages/app-lib/src/api/users.rs new file mode 100644 index 000000000..6d5f4019f --- /dev/null +++ b/packages/app-lib/src/api/users.rs @@ -0,0 +1,32 @@ +use crate::State; +use crate::util::fetch::fetch_json; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SearchUser { + pub id: String, + pub username: String, + pub avatar_url: Option, +} + +#[tracing::instrument] +pub async fn search_user(query: &str) -> crate::Result> { + let state = State::get().await?; + let query = urlencoding::encode(query); + + fetch_json( + Method::GET, + &format!( + "{}users/search?query={}", + env!("MODRINTH_API_URL_V3"), + query + ), + None, + None, + Some("/v3/users/search"), + &state.api_semaphore, + &state.pool, + ) + .await +} diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 4ab04cea1..ad7dca9e5 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -22,6 +22,24 @@ pub struct LabrinthError { pub route: Option, } +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SharedInstanceUnavailableReason { + Deleted, + AccessRevoked, + Quarantined, +} + +impl std::fmt::Display for SharedInstanceUnavailableReason { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Deleted => write!(fmt, "deleted"), + Self::AccessRevoked => write!(fmt, "access_revoked"), + Self::Quarantined => write!(fmt, "quarantined"), + } + } +} + #[derive(thiserror::Error, Debug)] pub enum ErrorKind { #[error("{0:?}")] @@ -103,6 +121,9 @@ pub enum ErrorKind { #[error("Invalid input: {0}")] InputError(String), + #[error("Shared instance unavailable: {0}")] + SharedInstanceUnavailable(SharedInstanceUnavailableReason), + #[error("Join handle error: {0}")] JoinError(#[from] tokio::task::JoinError), diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index e61dded89..3a1dabb95 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -234,6 +234,9 @@ pub enum CommandPayload { server: Option, singleplayer_world: Option, }, + InstallSharedInstanceInvite { + invite_id: String, + }, RunMRPack { // run or install .mrpack path: PathBuf, diff --git a/packages/app-lib/src/install/mod.rs b/packages/app-lib/src/install/mod.rs index bc8ba02f8..ec6844bda 100644 --- a/packages/app-lib/src/install/mod.rs +++ b/packages/app-lib/src/install/mod.rs @@ -3,6 +3,7 @@ pub mod events; pub mod model; pub mod recovery; pub mod runner; +mod shared_instance; pub mod store; pub use events::InstallProgressReporter; @@ -11,11 +12,13 @@ pub use model::{ InstallJobEventKind, InstallJobKind, InstallJobSnapshot, InstallJobStatus, InstallModpackPreview, InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit, InstallProgress, InstallProgressSecondary, - InstallRequest, + InstallRequest, SharedInstanceExternalFileData, SharedInstanceInstallData, + SharedInstanceInstallModpack, }; pub use runner::{ - cancel_job, create_instance, create_modpack_instance, dismiss_job, - duplicate_instance, get_job, import_instance, install_existing_instance, + cancel_job, create_instance, create_modpack_instance, + create_shared_instance, dismiss_job, duplicate_instance, get_job, + import_instance, install_existing_instance, install_pack_to_existing_instance, job_support_details, list_jobs, - retry_job, + retry_job, update_shared_instance, }; diff --git a/packages/app-lib/src/install/model.rs b/packages/app-lib/src/install/model.rs index 434654214..4f49c1db9 100644 --- a/packages/app-lib/src/install/model.rs +++ b/packages/app-lib/src/install/model.rs @@ -169,6 +169,9 @@ pub enum InstallRequest { #[serde(default)] post_install_edit: Option, }, + CreateSharedInstance { + data: SharedInstanceInstallData, + }, ImportInstance { launcher_type: ImportLauncherType, base_path: PathBuf, @@ -187,6 +190,10 @@ pub enum InstallRequest { #[serde(default)] post_install_edit: Option, }, + UpdateSharedInstance { + instance_id: String, + data: SharedInstanceInstallData, + }, } #[derive(Serialize, Deserialize, Clone, Debug, Default)] @@ -201,6 +208,46 @@ pub struct InstallPostInstallEdit { pub link: Option, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SharedInstanceInstallData { + pub shared_instance_id: String, + pub manager_id: Option, + #[serde(default)] + pub server_manager_name: Option, + #[serde(default)] + pub server_manager_icon_url: Option, + #[serde(default)] + pub instance_icon_url: Option, + #[serde(default)] + pub linked_user_id: Option, + pub name: String, + pub version: i32, + pub modrinth_ids: Vec, + #[serde(default)] + pub external_files: Vec, + pub modpack: Option, + pub game_version: String, + pub loader: ModLoader, + pub loader_version: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SharedInstanceExternalFileData { + pub file_name: String, + pub file_type: String, + pub url: String, + pub file_size: u64, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SharedInstanceInstallModpack { + pub project_id: String, + pub version_id: String, + pub title: String, + pub icon_url: Option, + pub dependency_count: usize, +} + impl InstallRequest { pub fn kind(&self) -> InstallJobKind { match self { @@ -208,6 +255,9 @@ impl InstallRequest { Self::CreateModpackInstance { .. } => { InstallJobKind::CreateModpackInstance } + Self::CreateSharedInstance { .. } => { + InstallJobKind::CreateSharedInstance + } Self::ImportInstance { .. } => InstallJobKind::ImportInstance, Self::DuplicateInstance { .. } => InstallJobKind::DuplicateInstance, Self::InstallExistingInstance { .. } => { @@ -216,13 +266,17 @@ impl InstallRequest { Self::InstallPackToExistingInstance { .. } => { InstallJobKind::InstallPackToExistingInstance } + Self::UpdateSharedInstance { .. } => { + InstallJobKind::UpdateSharedInstance + } } } pub fn target(&self) -> InstallTarget { match self { Self::InstallExistingInstance { instance_id, .. } - | Self::InstallPackToExistingInstance { instance_id, .. } => { + | Self::InstallPackToExistingInstance { instance_id, .. } + | Self::UpdateSharedInstance { instance_id, .. } => { InstallTarget::ExistingInstance { instance_id: instance_id.clone(), } @@ -234,7 +288,8 @@ impl InstallRequest { pub fn cleanup(&self) -> InstallCleanup { match self { Self::InstallExistingInstance { instance_id, .. } - | Self::InstallPackToExistingInstance { instance_id, .. } => { + | Self::InstallPackToExistingInstance { instance_id, .. } + | Self::UpdateSharedInstance { instance_id, .. } => { InstallCleanup::RestoreExistingInstance { instance_id: instance_id.clone(), } @@ -249,10 +304,12 @@ impl InstallRequest { pub enum InstallJobKind { CreateInstance, CreateModpackInstance, + CreateSharedInstance, ImportInstance, DuplicateInstance, InstallExistingInstance, InstallPackToExistingInstance, + UpdateSharedInstance, } impl InstallJobKind { @@ -260,24 +317,28 @@ impl InstallJobKind { match self { Self::CreateInstance => "create_instance", Self::CreateModpackInstance => "create_modpack_instance", + Self::CreateSharedInstance => "create_shared_instance", Self::ImportInstance => "import_instance", Self::DuplicateInstance => "duplicate_instance", Self::InstallExistingInstance => "install_existing_instance", Self::InstallPackToExistingInstance => { "install_pack_to_existing_instance" } + Self::UpdateSharedInstance => "update_shared_instance", } } pub fn from_stored_str(value: &str) -> Self { match value { "create_modpack_instance" => Self::CreateModpackInstance, + "create_shared_instance" => Self::CreateSharedInstance, "import_instance" => Self::ImportInstance, "duplicate_instance" => Self::DuplicateInstance, "install_existing_instance" => Self::InstallExistingInstance, "install_pack_to_existing_instance" => { Self::InstallPackToExistingInstance } + "update_shared_instance" => Self::UpdateSharedInstance, _ => Self::CreateInstance, } } @@ -484,6 +545,8 @@ pub struct InstallErrorView { pub phase: Option, pub message: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub api: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option, @@ -513,6 +576,7 @@ impl InstallErrorView { code: code.to_string(), phase: Some(phase), message: error.to_string(), + reason: None, api: match error.raw.as_ref() { crate::ErrorKind::LabrinthError(error) => { Some(InstallApiErrorDetails { @@ -538,6 +602,7 @@ impl InstallErrorView { code: code.to_string(), phase: Some(phase), message: message.into(), + reason: None, api: None, context: None, } diff --git a/packages/app-lib/src/install/recovery.rs b/packages/app-lib/src/install/recovery.rs index e74feb323..db56f2e2b 100644 --- a/packages/app-lib/src/install/recovery.rs +++ b/packages/app-lib/src/install/recovery.rs @@ -7,7 +7,257 @@ use super::model::{ use super::store; use crate::event::InstancePayloadType; use crate::event::emit::emit_instance; -use crate::state::State; +use crate::state::instances::adapters::sqlite::{content_rows, instance_rows}; +use crate::state::{ + ContentEntry, ContentSetRemoteRef, ContentSetRemoteRefType, + ContentSetSyncProvider, ContentSetSyncState, InstanceFile, + InstanceMetadata, State, +}; +use async_walkdir::WalkDir; +use chrono::Utc; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +const SHARED_INSTANCE_ROLLBACK_FILE: &str = "rollback.json"; +const SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR: &str = "instance"; + +#[derive(Deserialize, Serialize)] +struct SharedInstanceUpdateRollback { + files: Vec, + entries: Vec, +} + +pub(super) async fn prepare_shared_instance_update_backup( + job_id: Uuid, + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let staging_dir = state + .directories + .metadata_dir() + .join("install_job_backups") + .join(job_id.to_string()); + if tokio::fs::try_exists(&staging_dir).await? { + crate::util::io::remove_dir_all(&staging_dir).await?; + } + crate::util::io::create_dir_all(&staging_dir).await?; + + let result = async { + let files = content_rows::get_instance_files( + &metadata.instance.id, + &state.pool, + ) + .await?; + let entries = content_rows::get_content_entries( + &metadata.applied_content_set.id, + &state.pool, + ) + .await?; + let snapshot = SharedInstanceUpdateRollback { files, entries }; + let instance_path = state + .directories + .instances_dir() + .join(&metadata.instance.path); + copy_directory( + &instance_path, + &staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR), + state, + ) + .await?; + crate::util::io::write( + staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE), + serde_json::to_vec(&snapshot)?, + ) + .await?; + + Ok::<(), crate::Error>(()) + } + .await; + + if result.is_err() { + let _ = crate::util::io::remove_dir_all(&staging_dir).await; + } + result?; + Ok(staging_dir) +} + +pub(super) async fn clear_staging_dir(job_state: &InstallJobState) { + let Some(staging_dir) = &job_state.paths.staging_dir else { + return; + }; + if let Err(error) = crate::util::io::remove_dir_all(staging_dir).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + path = %staging_dir.display(), + "Failed to remove install rollback backup: {error}" + ); + } +} + +async fn restore_shared_instance_update( + staging_dir: &Path, + rollback: &super::model::InstallRollbackState, + state: &State, +) -> crate::Result<()> { + let snapshot = serde_json::from_slice::( + &crate::util::io::read(staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE)) + .await?, + )?; + let instance_path = state + .directories + .instances_dir() + .join(&rollback.instance.instance.path); + if tokio::fs::try_exists(&instance_path).await? { + crate::util::io::remove_dir_all(&instance_path).await?; + } + copy_directory( + &staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR), + &instance_path, + state, + ) + .await?; + content_rows::restore_instance_content_snapshot( + &rollback.instance.instance.id, + &snapshot.files, + &snapshot.entries, + &state.pool, + ) + .await?; + restore_instance_metadata(&rollback.instance, state).await?; + + Ok(()) +} + +async fn restore_instance_metadata( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let content_set_id = metadata.applied_content_set.id.as_str(); + let mut tx = state.pool.begin().await?; + instance_rows::update_instance(&metadata.instance, &mut tx).await?; + content_rows::update_content_set(&metadata.applied_content_set, &mut tx) + .await?; + instance_rows::upsert_instance_link( + &metadata.instance.id, + &metadata.link, + &mut tx, + ) + .await?; + instance_rows::set_shared_instance_attachment( + &metadata.instance.id, + metadata.shared_instance.as_ref(), + &mut tx, + ) + .await?; + instance_rows::replace_instance_groups( + &metadata.instance.id, + &metadata.groups, + &mut tx, + ) + .await?; + instance_rows::upsert_instance_launch_overrides( + &metadata.launch_overrides, + &mut tx, + ) + .await?; + content_rows::delete_content_set_remote_ref( + content_set_id, + ContentSetRemoteRefType::SharedContentSet, + &mut tx, + ) + .await?; + content_rows::delete_content_set_sync_state(content_set_id, &mut tx) + .await?; + if let Some(attachment) = &metadata.shared_instance { + content_rows::upsert_content_set_remote_ref( + &ContentSetRemoteRef { + content_set_id: content_set_id.to_string(), + ref_type: ContentSetRemoteRefType::SharedContentSet, + ref_id: attachment.id.clone(), + }, + &mut tx, + ) + .await?; + content_rows::upsert_content_set_sync_state( + &ContentSetSyncState { + content_set_id: content_set_id.to_string(), + provider: ContentSetSyncProvider::SharedInstance, + applied_update_id: attachment + .applied_version + .map(|value| value.to_string()), + latest_available_update_id: attachment + .latest_version + .map(|value| value.to_string()), + checked_at: Some(Utc::now()), + status: attachment.status, + }, + &mut tx, + ) + .await?; + } + tx.commit().await?; + + Ok(()) +} + +async fn copy_directory( + source: &Path, + target: &Path, + state: &State, +) -> crate::Result<()> { + crate::util::io::create_dir_all(target).await?; + let mut walker = WalkDir::new(source); + while let Some(entry) = walker.next().await { + let entry = entry.map_err(|error| { + crate::ErrorKind::FSError(format!( + "Failed to read instance backup path: {error}" + )) + })?; + let entry_path = entry.path(); + let relative_path = entry_path.strip_prefix(source)?; + let target_path = target.join(relative_path); + let file_type = entry.file_type().await?; + if file_type.is_dir() { + crate::util::io::create_dir_all(&target_path).await?; + } else if file_type.is_file() { + crate::util::fetch::copy( + &entry_path, + &target_path, + &state.io_semaphore, + ) + .await?; + } else if file_type.is_symlink() { + copy_symlink(&entry_path, &target_path).await?; + } + } + + Ok(()) +} + +async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> { + if let Some(parent) = target.parent() { + crate::util::io::create_dir_all(parent).await?; + } + let link_target = tokio::fs::read_link(source).await?; + + #[cfg(unix)] + tokio::fs::symlink(link_target, target).await?; + + #[cfg(windows)] + { + let metadata = tokio::fs::metadata(source).await?; + if metadata.is_dir() { + tokio::fs::symlink_dir(link_target, target).await?; + } else { + tokio::fs::symlink_file(link_target, target).await?; + } + } + + Ok(()) +} pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> { let jobs = store::list_interrupted_candidates(state).await?; @@ -61,6 +311,9 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> { state, ) .await?; + if job.state.rollback_error.is_none() { + clear_staging_dir(&job.state).await; + } emit_install_job(&record.snapshot()).await?; } @@ -96,6 +349,15 @@ fn display_from_request(state: &InstallJobState) -> Option { .. } => None, }, + InstallRequest::CreateSharedInstance { data } => { + Some(InstallJobDisplay { + title: data.name.clone(), + icon: data + .modpack + .as_ref() + .and_then(|modpack| modpack.icon_url.clone()), + }) + } InstallRequest::ImportInstance { instance_folder, .. } => Some(InstallJobDisplay { @@ -104,7 +366,8 @@ fn display_from_request(state: &InstallJobState) -> Option { }), InstallRequest::DuplicateInstance { .. } | InstallRequest::InstallExistingInstance { .. } - | InstallRequest::InstallPackToExistingInstance { .. } => { + | InstallRequest::InstallPackToExistingInstance { .. } + | InstallRequest::UpdateSharedInstance { .. } => { state.rollback.as_ref().map(|rollback| InstallJobDisplay { title: rollback.instance.instance.name.clone(), icon: rollback.instance.instance.icon_path.clone(), @@ -128,12 +391,25 @@ pub async fn apply_cleanup( } InstallCleanup::RestoreExistingInstance { instance_id } => { if let Some(rollback) = &job_state.rollback { - crate::state::instances::commands::set_instance_install_stage( - instance_id, - rollback.install_stage, - &state.pool, - ) - .await?; + if matches!( + &job_state.request, + InstallRequest::UpdateSharedInstance { .. } + ) && let Some(staging_dir) = &job_state.paths.staging_dir + { + restore_shared_instance_update( + staging_dir, + rollback, + state, + ) + .await?; + } else { + crate::state::instances::commands::set_instance_install_stage( + instance_id, + rollback.install_stage, + &state.pool, + ) + .await?; + } emit_instance(instance_id, InstancePayloadType::Edited).await?; } } diff --git a/packages/app-lib/src/install/runner.rs b/packages/app-lib/src/install/runner.rs index 36515a053..ac08df288 100644 --- a/packages/app-lib/src/install/runner.rs +++ b/packages/app-lib/src/install/runner.rs @@ -3,7 +3,13 @@ use super::model::{ InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay, InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus, InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit, - InstallRequest, InstallRollbackState, InstallTarget, + InstallProgress, InstallRequest, InstallRollbackState, InstallTarget, + SharedInstanceInstallData, +}; +use super::shared_instance::{ + apply_shared_instance_content, apply_shared_instance_update, + attach_pending_shared_instance, finalize_shared_instance_attachment, + shared_instance_link, shared_instance_pack_location, }; use super::{diagnostics, recovery, store}; use crate::ErrorKind; @@ -53,6 +59,19 @@ pub async fn create_modpack_instance( .await } +pub async fn create_shared_instance( + data: SharedInstanceInstallData, +) -> crate::Result { + start(InstallRequest::CreateSharedInstance { data }).await +} + +pub async fn update_shared_instance( + instance_id: String, + data: SharedInstanceInstallData, +) -> crate::Result { + start(InstallRequest::UpdateSharedInstance { instance_id, data }).await +} + pub async fn import_instance( launcher_type: crate::api::pack::import::ImportLauncherType, base_path: PathBuf, @@ -129,9 +148,15 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result { .into()); } + if job.state.rollback_error.is_some() { + recovery::apply_cleanup(&job.state, &state).await?; + recovery::clear_staging_dir(&job.state).await; + } + job.state.target = job.state.request.target(); job.state.cleanup = job.state.request.cleanup(); job.state.rollback = None; + job.state.paths.staging_dir = None; job.state.error = None; job.state.rollback_error = None; job.state.context = None; @@ -150,6 +175,7 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result { &state, ) .await?; + lock_existing_instance_if_needed(&job.state, &state).await?; emit_install_job(&record.snapshot()).await?; spawn_job(job_id); @@ -204,6 +230,9 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result { &state, ) .await?; + if job.state.rollback_error.is_none() { + recovery::clear_staging_dir(&job.state).await; + } emit_install_job(&record.snapshot()).await?; Ok(record.snapshot()) @@ -221,6 +250,7 @@ async fn start(request: InstallRequest) -> crate::Result { prepare_initial_instance(&mut job_state, &state).await?; let record = store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?; + lock_existing_instance_if_needed(&job_state, &state).await?; emit_install_job(&record.snapshot()).await?; spawn_job(id); Ok(record.snapshot()) @@ -296,6 +326,54 @@ async fn prepare_initial_instance( ); set_instance_id(job_state, metadata.instance.id); } + InstallRequest::CreateSharedInstance { data } => { + let shared_link = shared_instance_link(data.modpack.as_ref()); + let (game_version, loader, loader_version, icon_path) = + if let Some(modpack) = data.modpack.clone() { + let preview = get_instance_from_pack( + shared_instance_pack_location(modpack), + ) + .await?; + ( + preview.game_version, + preview.modloader, + preview.loader_version, + data.instance_icon_url + .clone() + .or_else(|| { + preview.icon.as_ref().map(|path| { + path.to_string_lossy().to_string() + }) + }) + .or_else(|| preview.icon_url.clone()), + ) + } else { + ( + data.game_version.clone(), + data.loader, + data.loader_version.clone(), + data.instance_icon_url.clone(), + ) + }; + let metadata = crate::api::instance::create( + data.name.clone(), + game_version, + loader, + loader_version, + icon_path, + shared_link, + ) + .await?; + set_display( + job_state, + metadata.instance.name, + metadata.instance.icon_path, + ); + let instance_id = metadata.instance.id; + attach_pending_shared_instance(&instance_id, &data, state).await?; + emit_instance(&instance_id, InstancePayloadType::Edited).await?; + set_instance_id(job_state, instance_id); + } InstallRequest::ImportInstance { instance_folder, .. } => { @@ -343,7 +421,8 @@ async fn prepare_initial_instance( InstallRequest::InstallExistingInstance { instance_id, .. } | InstallRequest::InstallPackToExistingInstance { instance_id, .. - } => { + } + | InstallRequest::UpdateSharedInstance { instance_id, .. } => { prepare_existing_rollback(job_state, state, &instance_id).await?; } } @@ -353,7 +432,7 @@ async fn prepare_initial_instance( fn spawn_job(job_id: Uuid) { tokio::spawn(async move { - if let Err(error) = run_job(job_id).await { + if let Err(error) = Box::pin(run_job(job_id)).await { tracing::error!( "Install job {job_id} failed to update state: {error}" ); @@ -387,16 +466,23 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> { .await?; emit_install_job(&record.snapshot()).await?; - let result = run_request(job_id, &mut job_state, &state).await; + let result = Box::pin(run_request(job_id, &mut job_state, &state)).await; if let Ok(record) = store::get_required(job_id, &state).await { job_state = record.state; } - match result { + let result = match result { Ok(instance_id) => { if let Some(instance_id) = instance_id { set_instance_id(&mut job_state, instance_id); } + finalize_existing_instance_success(&job_state, &state).await + } + Err(error) => Err(error), + }; + + match result { + Ok(()) => { job_state.record_event(InstallJobEventKind::JobSucceeded { instance_id: current_instance_id(&job_state), }); @@ -413,6 +499,7 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> { &state, ) .await?; + recovery::clear_staging_dir(&job_state).await; emit_install_job(&record.snapshot()).await?; } Err(error) => { @@ -459,6 +546,9 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> { &state, ) .await?; + if job_state.rollback_error.is_none() { + recovery::clear_staging_dir(&job_state).await; + } emit_install_job(&record.snapshot()).await?; return Err(error); } @@ -541,17 +631,39 @@ async fn run_request( modpack_details(&location), ) .await?; - install_pack( + Box::pin(install_pack( job_id, job_state, location, instance_id.clone(), DownloadReason::Modpack, - ) + )) .await?; apply_post_install_edit(&instance_id, post_install_edit).await?; Ok(Some(instance_id)) } + InstallRequest::CreateSharedInstance { data } => { + let Some(instance_id) = current_instance_id(job_state) else { + return Err(crate::ErrorKind::InputError( + "Install job is missing its instance id".to_string(), + ) + .into()); + }; + Box::pin(apply_shared_instance_content( + job_id, + job_state, + state, + &instance_id, + &data, + )) + .await?; + + finalize_shared_instance_attachment(&instance_id, &data, state) + .await?; + emit_instance(&instance_id, InstancePayloadType::Edited).await?; + + Ok(Some(instance_id)) + } InstallRequest::ImportInstance { launcher_type, base_path, @@ -629,6 +741,7 @@ async fn run_request( } InstallRequest::InstallExistingInstance { instance_id, force } => { prepare_existing_rollback(job_state, state, &instance_id).await?; + lock_existing_instance(&instance_id, state).await?; update_progress( job_id, job_state, @@ -660,6 +773,7 @@ async fn run_request( post_install_edit, } => { prepare_existing_rollback(job_state, state, &instance_id).await?; + lock_existing_instance(&instance_id, state).await?; let disabled_project_ids = remove_existing_pack_content( job_id, job_state, @@ -667,13 +781,13 @@ async fn run_request( &instance_id, ) .await?; - install_pack( + Box::pin(install_pack( job_id, job_state, location, instance_id.clone(), DownloadReason::Modpack, - ) + )) .await?; restore_disabled_projects( &instance_id, @@ -684,6 +798,49 @@ async fn run_request( apply_post_install_edit(&instance_id, post_install_edit).await?; Ok(Some(instance_id)) } + InstallRequest::UpdateSharedInstance { instance_id, data } => { + prepare_existing_rollback(job_state, state, &instance_id).await?; + lock_existing_instance(&instance_id, state).await?; + let rollback_instance = job_state + .rollback + .as_ref() + .map(|rollback| rollback.instance.clone()) + .ok_or_else(|| { + crate::ErrorKind::OtherError( + "Shared instance update rollback state is missing" + .to_string(), + ) + })?; + let staging_dir = recovery::prepare_shared_instance_update_backup( + job_id, + &rollback_instance, + state, + ) + .await?; + job_state.paths.staging_dir = Some(staging_dir); + let record = store::update_state(job_id, job_state, state).await?; + emit_install_job(&record.snapshot()).await?; + let disabled_project_ids = + disabled_project_ids(&instance_id, state).await?; + Box::pin(apply_shared_instance_update( + job_id, + job_state, + state, + &instance_id, + &data, + )) + .await?; + restore_disabled_projects( + &instance_id, + disabled_project_ids, + state, + ) + .await?; + finalize_shared_instance_attachment(&instance_id, &data, state) + .await?; + emit_instance(&instance_id, InstancePayloadType::Edited).await?; + Ok(Some(instance_id)) + } } } @@ -714,6 +871,20 @@ async fn apply_post_install_edit( Ok(()) } +async fn disabled_project_ids( + instance_id: &str, + state: &State, +) -> crate::Result> { + Ok(crate::state::instances::commands::list_project_files( + instance_id, + state, + ) + .await? + .into_iter() + .filter_map(|file| (!file.enabled).then_some(file.project_id?)) + .collect()) +} + async fn remove_existing_pack_content( job_id: Uuid, job_state: &InstallJobState, @@ -873,7 +1044,7 @@ async fn restore_disabled_projects( Ok(()) } -async fn install_pack( +pub(super) async fn install_pack( job_id: Uuid, job_state: &mut InstallJobState, location: CreatePackLocation, @@ -927,12 +1098,12 @@ async fn install_pack( } }; - install_zipped_mrpack_files_with_reporter( + Box::pin(install_zipped_mrpack_files_with_reporter( create_pack, false, reason, reporter, - ) + )) .await?; Ok(()) @@ -954,6 +1125,12 @@ async fn prepare_existing_rollback( "Unknown instance {instance_id}" )) })?; + if instance.quarantined { + return Err(crate::ErrorKind::InputError( + "Content in quarantined instances cannot be changed.".to_string(), + ) + .into()); + } let install_stage = instance.instance.install_stage; set_display( job_state, @@ -968,6 +1145,26 @@ async fn prepare_existing_rollback( instance_id: instance_id.to_string(), }; + Ok(()) +} + +async fn lock_existing_instance_if_needed( + job_state: &InstallJobState, + state: &State, +) -> crate::Result<()> { + if let InstallCleanup::RestoreExistingInstance { instance_id } = + &job_state.cleanup + { + lock_existing_instance(instance_id, state).await?; + } + + Ok(()) +} + +async fn lock_existing_instance( + instance_id: &str, + state: &State, +) -> crate::Result<()> { crate::state::instances::commands::set_instance_install_stage( instance_id, InstanceInstallStage::MinecraftInstalling, @@ -979,7 +1176,26 @@ async fn prepare_existing_rollback( Ok(()) } -async fn update_progress( +async fn finalize_existing_instance_success( + job_state: &InstallJobState, + state: &State, +) -> crate::Result<()> { + if let InstallCleanup::RestoreExistingInstance { instance_id } = + &job_state.cleanup + { + crate::state::instances::commands::set_instance_install_stage( + instance_id, + InstanceInstallStage::Installed, + &state.pool, + ) + .await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + } + + Ok(()) +} + +pub(super) async fn update_progress( job_id: Uuid, job_state: &mut InstallJobState, state: &State, @@ -992,6 +1208,25 @@ async fn update_progress( Ok(()) } +pub(super) async fn update_content_progress( + job_id: Uuid, + job_state: &mut InstallJobState, + state: &State, + current: u64, + total: u64, +) -> crate::Result<()> { + job_state.progress.phase = InstallPhaseId::DownloadingContent; + job_state.progress.progress = Some(InstallProgress { + current, + total, + secondary: None, + }); + job_state.progress.details = InstallPhaseDetails::Empty; + let record = store::update_state(job_id, job_state, state).await?; + emit_install_job(&record.snapshot()).await?; + Ok(()) +} + fn set_instance_id(job_state: &mut InstallJobState, instance_id: String) { job_state.target = match &job_state.target { InstallTarget::ExistingInstance { .. } => { @@ -1036,12 +1271,16 @@ fn install_error_view( error: &crate::Error, context: Option, ) -> InstallErrorView { - InstallErrorView::from_error( + let mut view = InstallErrorView::from_error( install_error_code(phase, error), phase, error, context, - ) + ); + if let ErrorKind::SharedInstanceUnavailable(reason) = error.raw.as_ref() { + view.reason = Some(*reason); + } + view } fn install_error_code( @@ -1051,6 +1290,9 @@ fn install_error_code( use InstallPhaseId::*; match error.raw.as_ref() { + ErrorKind::SharedInstanceUnavailable(_) => { + "shared_instance_unavailable" + } ErrorKind::InputError(_) => match phase { PreparingInstance | Finalizing => "instance_error", ResolvingPack | DownloadingPackFile | ReadingPackManifest => { @@ -1079,6 +1321,8 @@ fn install_error_code( }, ErrorKind::FetchError(_) | ErrorKind::ApiIsDownError(_) + | ErrorKind::WSError(_) + | ErrorKind::WSClosedError(_) | ErrorKind::Ratelimited { .. } => "network_error", ErrorKind::Any(_) if matches!( @@ -1123,7 +1367,9 @@ fn current_instance_id(job_state: &InstallJobState) -> Option { } } -fn modpack_details(location: &CreatePackLocation) -> InstallPhaseDetails { +pub(super) fn modpack_details( + location: &CreatePackLocation, +) -> InstallPhaseDetails { match location { CreatePackLocation::FromVersionId { project_id, diff --git a/packages/app-lib/src/install/shared_instance.rs b/packages/app-lib/src/install/shared_instance.rs new file mode 100644 index 000000000..36cbbc157 --- /dev/null +++ b/packages/app-lib/src/install/shared_instance.rs @@ -0,0 +1,1016 @@ +use super::events::InstallProgressReporter; +use super::model::{ + InstallJobState, InstallPhaseDetails, InstallPhaseId, + SharedInstanceExternalFileData, SharedInstanceInstallData, + SharedInstanceInstallModpack, +}; +use super::runner::{ + install_pack, modpack_details, update_content_progress, update_progress, +}; +use crate::api::instance::{ + CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS, + CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES, + read_bounded_config_bundle_entry, +}; +use crate::api::pack::install_from::CreatePackLocation; +use crate::state::instances::adapters::sqlite::content_rows; +use crate::state::{ + CachedEntry, ContentSetSyncStatus, ContentSourceKind, InstanceLink, + ProjectType, SharedInstanceAttachmentInput, SharedInstanceRole, State, +}; +use crate::util::fetch::{DownloadReason, REQWEST_CLIENT}; +use futures::StreamExt; +use path_util::SafeRelativeUtf8UnixPathBuf; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use uuid::Uuid; + +const MAX_SHARED_INSTANCE_EXTERNAL_FILE_SIZE: u64 = 500 * 1024 * 1024; +const MAX_SHARED_INSTANCE_INITIAL_BUFFER_SIZE: u64 = 8 * 1024 * 1024; + +pub(super) async fn attach_pending_shared_instance( + instance_id: &str, + data: &SharedInstanceInstallData, + state: &State, +) -> crate::Result<()> { + crate::state::attach_shared_instance( + instance_id, + SharedInstanceAttachmentInput { + id: data.shared_instance_id.clone(), + role: SharedInstanceRole::Member, + manager_id: data.manager_id.clone(), + server_manager_name: data.server_manager_name.clone(), + server_manager_icon_url: data.server_manager_icon_url.clone(), + linked_user_id: data.linked_user_id.clone(), + status: ContentSetSyncStatus::NotReady, + applied_version: None, + latest_version: Some(data.version), + }, + &state.pool, + ) + .await +} + +pub(super) async fn finalize_shared_instance_attachment( + instance_id: &str, + data: &SharedInstanceInstallData, + state: &State, +) -> crate::Result<()> { + crate::state::attach_shared_instance( + instance_id, + SharedInstanceAttachmentInput { + id: data.shared_instance_id.clone(), + role: SharedInstanceRole::Member, + manager_id: data.manager_id.clone(), + server_manager_name: data.server_manager_name.clone(), + server_manager_icon_url: data.server_manager_icon_url.clone(), + linked_user_id: data.linked_user_id.clone(), + status: ContentSetSyncStatus::UpToDate, + applied_version: Some(data.version), + latest_version: Some(data.version), + }, + &state.pool, + ) + .await +} + +#[derive(Clone, Debug)] +struct CurrentSharedInstanceProject { + project_id: String, + version_id: String, + relative_path: String, +} + +#[derive(Clone, Debug)] +struct CurrentSharedInstanceExternalFile { + relative_path: String, +} + +type SharedInstanceExternalFileKey = (ProjectType, String); + +#[derive(Default)] +struct CurrentSharedInstanceContent { + projects: HashMap, + external_files: HashMap< + SharedInstanceExternalFileKey, + CurrentSharedInstanceExternalFile, + >, +} + +#[derive(Clone, Debug)] +struct DesiredSharedInstanceProject { + project_id: String, + version_id: String, +} + +#[derive(Clone, Debug)] +struct DesiredSharedInstanceExternalFile { + file: SharedInstanceExternalFileData, + file_type: ProjectType, +} + +#[derive(Default)] +struct DesiredSharedInstanceContent { + projects: HashMap, + external_files: HashMap< + SharedInstanceExternalFileKey, + DesiredSharedInstanceExternalFile, + >, + config_bundle: Option, +} + +struct SharedInstanceProjectUpdate { + current: CurrentSharedInstanceProject, + desired: DesiredSharedInstanceProject, +} + +#[derive(Default)] +struct SharedInstanceApplyPlan { + configuration_changed: bool, + project_removals: Vec, + project_updates: Vec, + project_additions: Vec, + external_removals: Vec, + external_updates: Vec, + external_additions: Vec, + config_bundle: Option, +} + +impl SharedInstanceApplyPlan { + async fn build( + metadata: &crate::state::InstanceMetadata, + data: &SharedInstanceInstallData, + state: &State, + ) -> crate::Result { + if shared_instance_update_requires_full_apply(metadata, data) { + return Ok(Self { + configuration_changed: true, + ..Default::default() + }); + } + + let current = current_shared_instance_content(metadata, state).await?; + let desired = desired_shared_instance_content(data, state).await?; + let project_removals = current + .projects + .values() + .filter(|current| { + !desired.projects.contains_key(¤t.project_id) + }) + .cloned() + .collect(); + let external_removals = current + .external_files + .iter() + .filter(|(key, _)| !desired.external_files.contains_key(*key)) + .map(|(_, current)| current.clone()) + .collect(); + let mut plan = Self { + project_removals, + external_removals, + config_bundle: desired.config_bundle, + ..Default::default() + }; + + for desired in desired.projects.into_values() { + match current.projects.get(&desired.project_id) { + Some(current) if current.version_id != desired.version_id => { + plan.project_updates.push(SharedInstanceProjectUpdate { + current: current.clone(), + desired, + }); + } + None => plan.project_additions.push(desired), + Some(_) => {} + } + } + + for desired in desired.external_files.into_values() { + let key = (desired.file_type, desired.file.file_name.clone()); + match current.external_files.get(&key) { + Some(_) => { + plan.external_updates.push(desired); + } + _ => plan.external_additions.push(desired), + } + } + + Ok(plan) + } + + fn content_change_count(&self) -> u64 { + (self.project_updates.len() + + self.project_additions.len() + + self.external_updates.len() + + self.external_additions.len() + + usize::from(self.config_bundle.is_some())) as u64 + } +} + +pub(super) async fn apply_shared_instance_update( + job_id: Uuid, + job_state: &mut InstallJobState, + state: &State, + instance_id: &str, + data: &SharedInstanceInstallData, +) -> crate::Result<()> { + let metadata = crate::state::instances::commands::get_instance_metadata( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let plan = SharedInstanceApplyPlan::build(&metadata, data, state).await?; + + if plan.configuration_changed { + remove_existing_shared_instance_content(instance_id, state).await?; + Box::pin(apply_shared_instance_content( + job_id, + job_state, + state, + instance_id, + data, + )) + .await?; + return Ok(()); + } + + update_progress( + job_id, + job_state, + state, + InstallPhaseId::PreparingInstance, + InstallPhaseDetails::Instance { + name: data.name.clone(), + }, + ) + .await?; + + let content_change_count = plan.content_change_count(); + if content_change_count > 0 { + update_content_progress( + job_id, + job_state, + state, + 0, + content_change_count, + ) + .await?; + } + + for project in plan.project_removals { + crate::state::instances::commands::remove_project( + instance_id, + &project.relative_path, + state, + ) + .await?; + } + + for file in plan.external_removals { + crate::state::instances::commands::remove_project( + instance_id, + &file.relative_path, + state, + ) + .await?; + } + + let mut completed_content_changes = 0; + for update in plan.project_updates { + let new_path = + crate::state::instances::commands::add_project_from_version( + instance_id, + &update.desired.version_id, + DownloadReason::Update, + Some(update.current.version_id), + ContentSourceKind::SharedInstance, + state, + ) + .await?; + + if update.current.relative_path != new_path { + crate::state::instances::commands::remove_project( + instance_id, + &update.current.relative_path, + state, + ) + .await?; + } + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + for project in plan.project_additions { + crate::state::instances::commands::add_project_from_version( + instance_id, + &project.version_id, + DownloadReason::Standalone, + None, + ContentSourceKind::SharedInstance, + state, + ) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + for file in plan + .external_updates + .into_iter() + .chain(plan.external_additions) + { + install_shared_instance_external_file(instance_id, &file.file, state) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + if let Some(config_bundle) = plan.config_bundle { + install_shared_instance_external_file( + instance_id, + &config_bundle, + state, + ) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + crate::api::instance::edit( + instance_id, + crate::state::EditInstance { + name: Some(data.name.clone()), + link: Some(shared_instance_link(data.modpack.as_ref())), + ..Default::default() + }, + ) + .await?; + + Ok(()) +} + +fn shared_instance_update_requires_full_apply( + metadata: &crate::state::InstanceMetadata, + data: &SharedInstanceInstallData, +) -> bool { + let (current_modpack_project_id, current_modpack_version_id) = + match &metadata.link { + InstanceLink::SharedInstance { + modpack_project_id, + modpack_version_id, + } => (modpack_project_id.as_deref(), modpack_version_id.as_deref()), + _ => return true, + }; + let next_modpack_project_id = data + .modpack + .as_ref() + .map(|modpack| modpack.project_id.as_str()); + let next_modpack_version_id = data + .modpack + .as_ref() + .map(|modpack| modpack.version_id.as_str()); + + current_modpack_project_id != next_modpack_project_id + || current_modpack_version_id != next_modpack_version_id + || metadata.applied_content_set.game_version != data.game_version + || metadata.applied_content_set.loader != data.loader + || metadata.applied_content_set.loader_version != data.loader_version +} + +async fn current_shared_instance_content( + metadata: &crate::state::InstanceMetadata, + state: &State, +) -> crate::Result { + let entries = content_rows::get_content_entries( + &metadata.applied_content_set.id, + &state.pool, + ) + .await?; + let files = + content_rows::get_instance_files(&metadata.instance.id, &state.pool) + .await? + .into_iter() + .map(|file| (file.id.clone(), file)) + .collect::>(); + let version_ids_without_project = entries + .iter() + .filter(|entry| entry.source_kind == ContentSourceKind::SharedInstance) + .filter(|entry| entry.project_id.is_none()) + .filter_map(|entry| entry.version_id.clone()) + .collect::>(); + let versions_by_id = + shared_instance_versions_by_id(&version_ids_without_project, state) + .await?; + let mut content = CurrentSharedInstanceContent::default(); + + for entry in entries { + if entry.source_kind != ContentSourceKind::SharedInstance { + continue; + } + + let Some(file_id) = entry.file_id.as_ref() else { + continue; + }; + let Some(file) = files.get(file_id) else { + continue; + }; + + if let Some(version_id) = entry.version_id.clone() { + let Some(project_id) = entry.project_id.clone().or_else(|| { + versions_by_id + .get(&version_id) + .map(|version| version.project_id.clone()) + }) else { + continue; + }; + + content.projects.insert( + project_id.clone(), + CurrentSharedInstanceProject { + project_id, + version_id, + relative_path: file.relative_path.clone(), + }, + ); + } else { + content.external_files.insert( + (entry.project_type, file.file_name.clone()), + CurrentSharedInstanceExternalFile { + relative_path: file.relative_path.clone(), + }, + ); + } + } + + Ok(content) +} + +async fn desired_shared_instance_content( + data: &SharedInstanceInstallData, + state: &State, +) -> crate::Result { + let versions_by_id = + shared_instance_versions_by_id(&data.modrinth_ids, state).await?; + let mut content = DesiredSharedInstanceContent::default(); + + for version_id in &data.modrinth_ids { + let version = versions_by_id.get(version_id).ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Shared instance version {version_id} was not found" + )) + })?; + content.projects.insert( + version.project_id.clone(), + DesiredSharedInstanceProject { + project_id: version.project_id.clone(), + version_id: version.id.clone(), + }, + ); + } + + for file in &data.external_files { + if file.file_type == CONFIG_BUNDLE_FILE_TYPE { + if CONFIG_SYNC_ENABLED { + content.config_bundle = Some(file.clone()); + } + continue; + } + let file_type = + ProjectType::from_name(&file.file_type).ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown shared instance file type {}", + file.file_type + )) + })?; + content.external_files.insert( + (file_type, file.file_name.clone()), + DesiredSharedInstanceExternalFile { + file: file.clone(), + file_type, + }, + ); + } + + Ok(content) +} + +async fn shared_instance_versions_by_id( + version_ids: &[String], + state: &State, +) -> crate::Result> { + if version_ids.is_empty() { + return Ok(HashMap::new()); + } + + let mut ids = version_ids.iter().map(String::as_str).collect::>(); + ids.sort_unstable(); + ids.dedup(); + let versions = CachedEntry::get_version_many( + &ids, + None, + &state.pool, + &state.api_semaphore, + ) + .await?; + let versions_by_id = versions + .into_iter() + .map(|version| (version.id.clone(), version)) + .collect::>(); + + for id in ids { + if !versions_by_id.contains_key(id) { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance version {id} was not found" + )) + .into()); + } + } + + Ok(versions_by_id) +} + +pub(super) async fn apply_shared_instance_content( + job_id: Uuid, + job_state: &mut InstallJobState, + state: &State, + instance_id: &str, + data: &SharedInstanceInstallData, +) -> crate::Result<()> { + update_progress( + job_id, + job_state, + state, + InstallPhaseId::PreparingInstance, + InstallPhaseDetails::Instance { + name: data.name.clone(), + }, + ) + .await?; + + if let Some(modpack) = data.modpack.clone() { + let location = shared_instance_pack_location(modpack); + update_progress( + job_id, + job_state, + state, + InstallPhaseId::ResolvingPack, + modpack_details(&location), + ) + .await?; + Box::pin(install_pack( + job_id, + job_state, + location, + instance_id.to_string(), + DownloadReason::Modpack, + )) + .await?; + } else { + crate::api::instance::edit( + instance_id, + crate::state::EditInstance { + content_set_patch: Some(crate::state::AppliedContentSetPatch { + source_kind: Some(ContentSourceKind::SharedInstance), + game_version: Some(data.game_version.clone()), + protocol_version: Some(None), + loader: Some(data.loader), + loader_version: Some(data.loader_version.clone()), + }), + ..Default::default() + }, + ) + .await?; + update_progress( + job_id, + job_state, + state, + InstallPhaseId::DownloadingMinecraft, + InstallPhaseDetails::Minecraft { + game_version: data.game_version.clone(), + loader: data.loader, + }, + ) + .await?; + let context = + crate::state::instances::commands::get_instance_launch_context( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + crate::launcher::install_minecraft_with_reporter( + &context, + false, + Some(InstallProgressReporter::new(job_id, job_state.clone())), + ) + .await?; + } + + if !data.modrinth_ids.is_empty() || !data.external_files.is_empty() { + let content_change_count = + data.modrinth_ids.len() as u64 + data.external_files.len() as u64; + update_content_progress( + job_id, + job_state, + state, + 0, + content_change_count, + ) + .await?; + let mut completed_content_changes = 0; + for version_id in &data.modrinth_ids { + crate::state::instances::commands::add_project_from_version( + instance_id, + version_id, + DownloadReason::Standalone, + None, + ContentSourceKind::SharedInstance, + state, + ) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + + for file in &data.external_files { + install_shared_instance_external_file(instance_id, file, state) + .await?; + completed_content_changes += 1; + update_content_progress( + job_id, + job_state, + state, + completed_content_changes, + content_change_count, + ) + .await?; + } + } + + crate::api::instance::edit( + instance_id, + crate::state::EditInstance { + name: Some(data.name.clone()), + link: Some(shared_instance_link(data.modpack.as_ref())), + ..Default::default() + }, + ) + .await?; + + Ok(()) +} + +pub(super) async fn remove_existing_shared_instance_content( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + let _content_lock = state.lock_instance_content(instance_id).await; + let metadata = crate::state::instances::commands::get_instance_metadata( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let entries = content_rows::get_content_entries( + &metadata.applied_content_set.id, + &state.pool, + ) + .await?; + let files = content_rows::get_instance_files(instance_id, &state.pool) + .await? + .into_iter() + .map(|file| (file.id.clone(), file)) + .collect::>(); + let base = state + .directories + .instances_dir() + .join(&metadata.instance.path); + + let mut removed_file_ids = HashSet::new(); + for entry in entries { + if !entry.source_kind.is_shared_instance_managed() { + continue; + } + + let Some(file_id) = entry.file_id else { + continue; + }; + if !removed_file_ids.insert(file_id.clone()) { + continue; + } + + let Some(file) = files.get(&file_id) else { + continue; + }; + crate::util::io::remove_file(base.join(&file.relative_path)).await?; + let mut tx = state.pool.begin().await?; + content_rows::remove_content_entries_for_file( + &metadata.applied_content_set.id, + &file.id, + &mut tx, + ) + .await?; + content_rows::remove_instance_file_by_relative_path( + instance_id, + &file.relative_path, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + Ok(()) +} + +pub(super) fn shared_instance_pack_location( + modpack: SharedInstanceInstallModpack, +) -> CreatePackLocation { + CreatePackLocation::FromVersionId { + project_id: modpack.project_id, + version_id: modpack.version_id, + title: modpack.title, + icon_url: modpack.icon_url, + } +} + +pub(super) fn shared_instance_link( + modpack: Option<&SharedInstanceInstallModpack>, +) -> InstanceLink { + InstanceLink::SharedInstance { + modpack_project_id: modpack.map(|modpack| modpack.project_id.clone()), + modpack_version_id: modpack.map(|modpack| modpack.version_id.clone()), + } +} + +async fn install_shared_instance_external_file( + instance_id: &str, + file: &SharedInstanceExternalFileData, + state: &State, +) -> crate::Result<()> { + if file.file_type == CONFIG_BUNDLE_FILE_TYPE && !CONFIG_SYNC_ENABLED { + return Ok(()); + } + + validate_shared_instance_external_file_name(&file.file_name)?; + + if file.file_size > MAX_SHARED_INSTANCE_EXTERNAL_FILE_SIZE { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance external file {} exceeds the maximum size", + file.file_name + )) + .into()); + } + + let response = REQWEST_CLIENT.get(&file.url).send().await?; + + if !response.status().is_success() { + return Err(crate::ErrorKind::OtherError(format!( + "Shared instance external file download failed with status {}", + response.status() + )) + .into()); + } + + if response + .content_length() + .is_some_and(|content_length| content_length != file.file_size) + { + return Err(crate::ErrorKind::OtherError(format!( + "Shared instance external file {} has an unexpected size", + file.file_name + )) + .into()); + } + + let mut stream = response.bytes_stream(); + let initial_capacity = usize::try_from( + file.file_size.min(MAX_SHARED_INSTANCE_INITIAL_BUFFER_SIZE), + ) + .map_err(|_| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {} is too large", + file.file_name + )) + })?; + let mut bytes = Vec::with_capacity(initial_capacity); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + let downloaded_size = (bytes.len() as u64) + .checked_add(chunk.len() as u64) + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {} is too large", + file.file_name + )) + })?; + if downloaded_size > file.file_size { + return Err(crate::ErrorKind::OtherError(format!( + "Shared instance external file {} has an unexpected size", + file.file_name + )) + .into()); + } + bytes.extend_from_slice(&chunk); + } + + if bytes.len() as u64 != file.file_size { + return Err(crate::ErrorKind::OtherError(format!( + "Shared instance external file {} has an unexpected size", + file.file_name + )) + .into()); + } + let bytes = bytes::Bytes::from(bytes); + + if file.file_type == CONFIG_BUNDLE_FILE_TYPE { + return install_shared_instance_config_bundle( + instance_id, + bytes, + state, + ) + .await; + } + + let project_type = + ProjectType::from_name(&file.file_type).ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown shared instance file type {}", + file.file_type + )) + })?; + + crate::state::instances::commands::add_project_bytes( + instance_id, + &file.file_name, + bytes, + None, + Some(project_type), + ContentSourceKind::SharedInstance, + None, + None, + state, + ) + .await?; + + Ok(()) +} + +fn validate_shared_instance_external_file_name( + file_name: &str, +) -> crate::Result<()> { + let path = SafeRelativeUtf8UnixPathBuf::try_from(file_name.to_string()) + .map_err(|_| { + crate::ErrorKind::InputError(format!( + "Shared instance external file {file_name} has an invalid file name" + )) + })?; + if file_name == "." + || file_name.contains('/') + || file_name.contains('\\') + || path.components().count() != 1 + { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance external file {file_name} has an invalid file name" + )) + .into()); + } + + Ok(()) +} + +async fn install_shared_instance_config_bundle( + instance_id: &str, + bytes: bytes::Bytes, + state: &State, +) -> crate::Result<()> { + let files = + tokio::task::spawn_blocking(move || read_config_bundle(bytes.as_ref())) + .await??; + let metadata = crate::state::instances::commands::get_instance_metadata( + instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let config_path = state + .directories + .instances_dir() + .join(metadata.instance.path) + .join(CONFIG_DIRECTORY); + crate::util::io::create_dir_all(&config_path).await?; + + for (relative_path, bytes) in files { + let path = config_path.join(relative_path); + if let Some(parent) = path.parent() { + crate::util::io::create_dir_all(parent).await?; + } + crate::util::io::write(path, bytes).await?; + } + + Ok(()) +} + +fn read_config_bundle(bytes: &[u8]) -> crate::Result)>> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|error| { + crate::ErrorKind::InputError(format!( + "Invalid shared instance config bundle: {error}" + )) + })?; + if archive.len() > MAX_CONFIG_BUNDLE_ENTRIES { + return Err(crate::ErrorKind::InputError( + "Shared instance config bundle contains too many entries" + .to_string(), + ) + .into()); + } + let mut files = Vec::new(); + let mut total_size = 0; + + for index in 0..archive.len() { + let file = archive.by_index(index).map_err(|error| { + crate::ErrorKind::InputError(format!( + "Invalid shared instance config bundle entry: {error}" + )) + })?; + if file.is_dir() { + continue; + } + let path = file.enclosed_name().ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance config bundle contains an unsafe path" + .to_string(), + ) + })?; + if !is_supported_config_file(&path) { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance config bundle contains unsupported file {}", + path.display() + )) + .into()); + } + let declared_size = file.size(); + let bytes = read_bounded_config_bundle_entry( + file, + declared_size, + &mut total_size, + )?; + files.push((path.to_path_buf(), bytes)); + } + + Ok(files) +} + +fn is_supported_config_file(path: &std::path::Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + CONFIG_FILE_EXTENSIONS + .iter() + .any(|candidate| extension.eq_ignore_ascii_case(candidate)) + }) +} diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index d178335cf..55783693b 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -290,6 +290,7 @@ pub async fn install_minecraft_with_reporter( }; let state = State::get().await?; + let previous_install_stage = instance.install_stage; crate::state::instances::commands::set_instance_install_stage( &instance.id, @@ -299,6 +300,7 @@ pub async fn install_minecraft_with_reporter( .await?; emit_instance(&instance.id, InstancePayloadType::Edited).await?; + let result = async { let instance_path = get_instance_full_path(&instance.path).await?; if let Some(reporter) = &reporter { reporter @@ -618,7 +620,34 @@ pub async fn install_minecraft_with_reporter( emit_loading(loading_bar, 1.0, Some("Finished installing"))?; } - Ok(()) + Ok::<(), crate::Error>(()) + } + .await; + + if result.is_err() { + if let Err(error) = + crate::state::instances::commands::set_instance_install_stage( + &instance.id, + previous_install_stage, + &state.pool, + ) + .await + { + tracing::error!( + "Failed to restore install stage for instance {}: {error}", + instance.id + ); + } else if let Err(error) = + emit_instance(&instance.id, InstancePayloadType::Edited).await + { + tracing::error!( + "Failed to emit restored install stage for instance {}: {error}", + instance.id + ); + } + } + + result } pub async fn install_minecraft_for_instance_id_with_reporter( diff --git a/packages/app-lib/src/state/friends.rs b/packages/app-lib/src/state/friends.rs index 9e59febeb..730d54fe3 100644 --- a/packages/app-lib/src/state/friends.rs +++ b/packages/app-lib/src/state/friends.rs @@ -240,6 +240,11 @@ impl FriendsSocket { } async fn handle_notification(notification: Value) -> crate::Result<()> { + tracing::info!( + notification = %notification, + "Received websocket notification payload" + ); + if notification .get("body") .and_then(|body| body.get("type")) diff --git a/packages/app-lib/src/state/instance_types.rs b/packages/app-lib/src/state/instance_types.rs index 4fefab25a..c3b6567fa 100644 --- a/packages/app-lib/src/state/instance_types.rs +++ b/packages/app-lib/src/state/instance_types.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use std::path::Path; +use super::instances::ContentSourceKind; + #[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum InstanceInstallStage { @@ -122,6 +124,7 @@ pub struct ContentFile { pub metadata: Option, pub update_version_id: Option, pub project_type: ProjectType, + pub source_kind: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -130,7 +133,7 @@ pub struct FileMetadata { pub version_id: String, } -#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] pub enum ProjectType { Mod, @@ -186,6 +189,18 @@ impl ProjectType { } } + pub fn from_name(name: &str) -> Option { + match name { + "mod" | "mods" => Some(ProjectType::Mod), + "datapack" | "datapacks" => Some(ProjectType::DataPack), + "resourcepack" | "resourcepacks" => Some(ProjectType::ResourcePack), + "shader" | "shaderpack" | "shaderpacks" => { + Some(ProjectType::ShaderPack) + } + _ => None, + } + } + pub fn get_folder(&self) -> &'static str { match self { ProjectType::Mod => "mods", diff --git a/packages/app-lib/src/state/instances/adapters/filesystem.rs b/packages/app-lib/src/state/instances/adapters/filesystem.rs index 73c8ce4f5..2d054fc90 100644 --- a/packages/app-lib/src/state/instances/adapters/filesystem.rs +++ b/packages/app-lib/src/state/instances/adapters/filesystem.rs @@ -89,6 +89,9 @@ fn is_scannable_project_file( ProjectType::Mod => extension.eq_ignore_ascii_case("jar"), ProjectType::DataPack | ProjectType::ResourcePack - | ProjectType::ShaderPack => extension.eq_ignore_ascii_case("zip"), + | ProjectType::ShaderPack => { + extension.eq_ignore_ascii_case("zip") + || extension.eq_ignore_ascii_case("jar") + } } } diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs index 74a58bf93..ff51044eb 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs @@ -374,6 +374,56 @@ where rows.into_iter().map(TryInto::try_into).collect() } +pub(crate) async fn upsert_content_set_remote_ref( + remote_ref: &ContentSetRemoteRef, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let content_set_id = remote_ref.content_set_id.as_str(); + let ref_type = remote_ref.ref_type.as_str(); + let ref_id = remote_ref.ref_id.as_str(); + + sqlx::query!( + " + INSERT INTO instance_content_set_remote_refs ( + content_set_id, + ref_type, + ref_id + ) + VALUES (?, ?, ?) + ON CONFLICT (content_set_id, ref_type) DO UPDATE SET + ref_id = excluded.ref_id + ", + content_set_id, + ref_type, + ref_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn delete_content_set_remote_ref( + content_set_id: &str, + ref_type: ContentSetRemoteRefType, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let ref_type = ref_type.as_str(); + + sqlx::query!( + " + DELETE FROM instance_content_set_remote_refs + WHERE content_set_id = ? AND ref_type = ? + ", + content_set_id, + ref_type, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + pub(crate) async fn get_content_set_sync_state<'e, E>( content_set_id: &str, exec: E, @@ -396,6 +446,66 @@ where row.map(TryInto::try_into).transpose() } +pub(crate) async fn upsert_content_set_sync_state( + sync_state: &ContentSetSyncState, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let content_set_id = sync_state.content_set_id.as_str(); + let provider = sync_state.provider.as_str(); + let applied_update_id = sync_state.applied_update_id.as_deref(); + let latest_available_update_id = + sync_state.latest_available_update_id.as_deref(); + let checked_at = sync_state.checked_at.map(|value| value.timestamp()); + let status = sync_state.status.as_str(); + + sqlx::query!( + " + INSERT INTO instance_content_set_sync_state ( + content_set_id, + provider, + applied_update_id, + latest_available_update_id, + checked_at, + status + ) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (content_set_id) DO UPDATE SET + provider = excluded.provider, + applied_update_id = excluded.applied_update_id, + latest_available_update_id = excluded.latest_available_update_id, + checked_at = excluded.checked_at, + status = excluded.status + ", + content_set_id, + provider, + applied_update_id, + latest_available_update_id, + checked_at, + status, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn delete_content_set_sync_state( + content_set_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + " + DELETE FROM instance_content_set_sync_state + WHERE content_set_id = ? + ", + content_set_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + pub(crate) async fn get_instance_files<'e, E>( instance_id: &str, exec: E, @@ -523,6 +633,100 @@ pub(crate) async fn upsert_instance_file( row.try_into() } +async fn insert_content_entry( + entry: &ContentEntry, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let id = entry.id.as_str(); + let instance_id = entry.instance_id.as_str(); + let content_set_id = entry.content_set_id.as_str(); + let file_id = entry.file_id.as_deref(); + let project_type = entry.project_type.get_name(); + let project_id = entry.project_id.as_deref(); + let version_id = entry.version_id.as_deref(); + let source_kind = entry.source_kind.as_str(); + let server_requirement = entry.server_requirement.as_str(); + let client_requirement = entry.client_requirement.as_str(); + let enabled = i64::from(entry.enabled); + let added_at = entry.added_at.timestamp(); + let modified_at = entry.modified_at.timestamp(); + + sqlx::query( + " + INSERT INTO instance_content_entries ( + id, + instance_id, + content_set_id, + file_id, + project_type, + project_id, + version_id, + source_kind, + server_requirement, + client_requirement, + enabled, + added_at, + modified_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ", + ) + .bind(id) + .bind(instance_id) + .bind(content_set_id) + .bind(file_id) + .bind(project_type) + .bind(project_id) + .bind(version_id) + .bind(source_kind) + .bind(server_requirement) + .bind(client_requirement) + .bind(enabled) + .bind(added_at) + .bind(modified_at) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn restore_instance_content_snapshot( + instance_id: &str, + files: &[InstanceFile], + entries: &[ContentEntry], + pool: &SqlitePool, +) -> crate::Result<()> { + let mut tx = pool.begin().await?; + sqlx::query( + " + DELETE FROM instance_content_entries + WHERE instance_id = ? + ", + ) + .bind(instance_id) + .execute(&mut *tx) + .await?; + sqlx::query( + " + DELETE FROM instance_files + WHERE instance_id = ? + ", + ) + .bind(instance_id) + .execute(&mut *tx) + .await?; + + for file in files { + upsert_instance_file(file, &mut tx).await?; + } + for entry in entries { + insert_content_entry(entry, &mut tx).await?; + } + + tx.commit().await?; + Ok(()) +} + pub(crate) async fn get_content_entries<'e, E>( content_set_id: &str, exec: E, @@ -1091,16 +1295,12 @@ pub(crate) async fn upsert_content_update_check( } fn project_type_from_str(value: &str) -> crate::Result { - match value { - "mod" => Ok(ProjectType::Mod), - "datapack" => Ok(ProjectType::DataPack), - "resourcepack" => Ok(ProjectType::ResourcePack), - "shader" | "shaderpack" => Ok(ProjectType::ShaderPack), - other => Err(crate::ErrorKind::InputError(format!( - "Unknown content project type {other}" + ProjectType::from_name(value).ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown content project type {value}" )) - .into()), - } + .into() + }) } fn timestamp(value: i64) -> DateTime { diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index 5a4a0e614..6e97bf4ab 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -1,9 +1,10 @@ #![allow(dead_code)] use crate::state::instances::{ - ContentSet, ContentSetStatus, ContentSourceKind, Instance, - InstanceLaunchContext, InstanceLaunchOverrides, - InstanceLaunchOverridesData, InstanceLink, playtime_to_storage, + ContentSet, ContentSetStatus, ContentSetSyncStatus, ContentSourceKind, + Instance, InstanceLaunchContext, InstanceLaunchOverrides, + InstanceLaunchOverridesData, InstanceLink, SharedInstanceAttachment, + SharedInstanceRole, playtime_to_storage, }; use crate::state::{ InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel, @@ -73,6 +74,9 @@ pub(crate) struct InstanceLinkRow { pub hosting_instance_ids: Option, pub hosting_active_instance_id: Option, pub shared_instance_id: Option, + pub shared_instance_role: Option, + pub shared_instance_manager_id: Option, + pub shared_instance_linked_user_id: Option, pub imported_name: Option, pub imported_version_number: Option, pub imported_filename: Option, @@ -137,10 +141,8 @@ impl TryFrom for InstanceLink { filename: row.imported_filename, }), "shared_instance" => Ok(Self::SharedInstance { - shared_instance_id: parse_uuid( - row.shared_instance_id, - "shared_instance_id", - )?, + modpack_project_id: row.modrinth_project_id, + modpack_version_id: row.modrinth_version_id, }), other => Err(crate::ErrorKind::InputError(format!( "Unknown instance link kind {other}" @@ -161,6 +163,7 @@ pub(crate) struct InstanceMetadataRecord { pub instance: Instance, pub applied_content_set: ContentSet, pub link: InstanceLink, + pub shared_instance: Option, pub groups: Vec, pub launch_overrides: InstanceLaunchOverrides, } @@ -207,6 +210,14 @@ struct InstanceMetadataRow { hosting_instance_ids: Option, hosting_active_instance_id: Option, shared_instance_id: Option, + shared_instance_role: Option, + shared_instance_manager_id: Option, + shared_instance_server_manager_name: Option, + shared_instance_server_manager_icon_url: Option, + shared_instance_linked_user_id: Option, + shared_sync_applied_update_id: Option, + shared_sync_latest_available_update_id: Option, + shared_sync_status: Option, imported_name: Option, imported_version_number: Option, imported_filename: Option, @@ -300,12 +311,28 @@ impl InstanceMetadataRow { hosting_server_id: self.hosting_server_id, hosting_instance_ids: self.hosting_instance_ids, hosting_active_instance_id: self.hosting_active_instance_id, - shared_instance_id: self.shared_instance_id, + shared_instance_id: self.shared_instance_id.clone(), + shared_instance_role: self.shared_instance_role.clone(), + shared_instance_manager_id: self.shared_instance_manager_id.clone(), + shared_instance_linked_user_id: self + .shared_instance_linked_user_id + .clone(), imported_name: self.imported_name, imported_version_number: self.imported_version_number, imported_filename: self.imported_filename, } .try_into()?; + let shared_instance = shared_instance_attachment( + self.shared_instance_id, + self.shared_instance_role, + self.shared_instance_manager_id, + self.shared_instance_server_manager_name, + self.shared_instance_server_manager_icon_url, + self.shared_instance_linked_user_id, + self.shared_sync_status, + self.shared_sync_applied_update_id, + self.shared_sync_latest_available_update_id, + )?; let groups = parse_groups(self.groups)?; let launch_overrides = launch_overrides_from_json(instance_id, self.launch_overrides)?; @@ -314,6 +341,7 @@ impl InstanceMetadataRow { instance, applied_content_set, link, + shared_instance, groups, launch_overrides, }) @@ -418,75 +446,173 @@ where Ok(row) } +pub(crate) async fn is_instance_quarantined<'e, E>( + instance_id: &str, + exec: E, +) -> crate::Result +where + E: Executor<'e, Database = Sqlite>, +{ + let quarantined = sqlx::query_scalar::<_, bool>( + " + SELECT EXISTS ( + SELECT 1 + FROM instance_quarantines + WHERE instance_id = ? + ) + ", + ) + .bind(instance_id) + .fetch_one(exec) + .await?; + + Ok(quarantined) +} + +pub(crate) async fn get_quarantined_instance_ids<'e, E>( + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let instance_ids = sqlx::query_scalar::<_, String>( + " + SELECT instance_id + FROM instance_quarantines + ", + ) + .fetch_all(exec) + .await?; + + Ok(instance_ids.into_iter().collect()) +} + +pub(crate) async fn set_instance_quarantined( + instance_id: &str, + quarantined: bool, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + if quarantined { + sqlx::query( + " + INSERT INTO instance_quarantines (instance_id) + VALUES (?) + ON CONFLICT (instance_id) DO NOTHING + ", + ) + .bind(instance_id) + .execute(&mut **tx) + .await?; + } else { + sqlx::query( + " + DELETE FROM instance_quarantines + WHERE instance_id = ? + ", + ) + .bind(instance_id) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +macro_rules! query_instance_metadata { + ( + $prefix:literal, + $from:literal, + $suffix:literal, + $arg:expr $(,)? + ) => { + sqlx::query_as!( + InstanceMetadataRow, + $prefix + + r#" + SELECT + i.id AS "id!: String", + i.path AS "path!: String", + i.applied_content_set_id AS "applied_content_set_id?: String", + i.install_stage AS "install_stage!: String", + i.launcher_feature_version AS "launcher_feature_version!: String", + i.update_channel AS "update_channel!: String", + i.name AS "name!: String", + i.icon_path AS "icon_path?: String", + i.created AS "created!: i64", + i.modified AS "modified!: i64", + i.last_played AS "last_played?: i64", + i.submitted_time_played AS "submitted_time_played!: i64", + i.recent_time_played AS "recent_time_played!: i64", + cs.id AS "content_set_id?: String", + cs.instance_id AS "content_set_instance_id?: String", + cs.name AS "content_set_name?: String", + cs.source_kind AS "content_set_source_kind?: String", + cs.status AS "content_set_status?: String", + cs.game_version AS "content_set_game_version?: String", + cs.protocol_version AS "content_set_protocol_version?: i64", + cs.loader AS "content_set_loader?: String", + cs.loader_version AS "content_set_loader_version?: String", + cs.created AS "content_set_created?: i64", + cs.modified AS "content_set_modified?: i64", + COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", + link.modrinth_project_id AS "modrinth_project_id?: String", + link.modrinth_version_id AS "modrinth_version_id?: String", + link.server_project_id AS "server_project_id?: String", + link.content_project_id AS "content_project_id?: String", + link.content_version_id AS "content_version_id?: String", + link.hosting_server_id AS "hosting_server_id?: String", + json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", + link.hosting_active_instance_id AS "hosting_active_instance_id?: String", + link.shared_instance_id AS "shared_instance_id?: String", + link.shared_instance_role AS "shared_instance_role?: String", + link.shared_instance_manager_id AS "shared_instance_manager_id?: String", + link.shared_instance_server_manager_name AS "shared_instance_server_manager_name?: String", + link.shared_instance_server_manager_icon_url AS "shared_instance_server_manager_icon_url?: String", + link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String", + sync.applied_update_id AS "shared_sync_applied_update_id?: String", + sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String", + sync.status AS "shared_sync_status?: String", + link.imported_name AS "imported_name?: String", + link.imported_version_number AS "imported_version_number?: String", + link.imported_filename AS "imported_filename?: String", + COALESCE(( + SELECT json_group_array(group_name) + FROM ( + SELECT group_name + FROM instance_groups + WHERE instance_id = i.id + ORDER BY group_name + ) + ), '[]') AS "groups!: String", + json(overrides.overrides) AS "launch_overrides?: String" + "# + + $from + + r#" + LEFT JOIN instance_content_sets cs + ON cs.id = i.applied_content_set_id + AND cs.instance_id = i.id + LEFT JOIN instance_links link + ON link.instance_id = i.id + LEFT JOIN instance_content_set_sync_state sync + ON sync.content_set_id = cs.id + AND sync.provider = 'shared_instance' + LEFT JOIN instance_launch_overrides overrides + ON overrides.instance_id = i.id + "# + + $suffix, + $arg, + ) + }; +} + pub(crate) async fn get_instance_metadata_by_id( id: &str, pool: &SqlitePool, ) -> crate::Result> { - let row = sqlx::query_as!( - InstanceMetadataRow, - r#" - SELECT - i.id AS "id!: String", - i.path AS "path!: String", - i.applied_content_set_id AS "applied_content_set_id?: String", - i.install_stage AS "install_stage!: String", - i.launcher_feature_version AS "launcher_feature_version!: String", - i.update_channel AS "update_channel!: String", - i.name AS "name!: String", - i.icon_path AS "icon_path?: String", - i.created AS "created!: i64", - i.modified AS "modified!: i64", - i.last_played AS "last_played?: i64", - i.submitted_time_played AS "submitted_time_played!: i64", - i.recent_time_played AS "recent_time_played!: i64", - cs.id AS "content_set_id?: String", - cs.instance_id AS "content_set_instance_id?: String", - cs.name AS "content_set_name?: String", - cs.source_kind AS "content_set_source_kind?: String", - cs.status AS "content_set_status?: String", - cs.game_version AS "content_set_game_version?: String", - cs.protocol_version AS "content_set_protocol_version?: i64", - cs.loader AS "content_set_loader?: String", - cs.loader_version AS "content_set_loader_version?: String", - cs.created AS "content_set_created?: i64", - cs.modified AS "content_set_modified?: i64", - COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", - link.modrinth_project_id AS "modrinth_project_id?: String", - link.modrinth_version_id AS "modrinth_version_id?: String", - link.server_project_id AS "server_project_id?: String", - link.content_project_id AS "content_project_id?: String", - link.content_version_id AS "content_version_id?: String", - link.hosting_server_id AS "hosting_server_id?: String", - json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", - link.hosting_active_instance_id AS "hosting_active_instance_id?: String", - link.shared_instance_id AS "shared_instance_id?: String", - link.imported_name AS "imported_name?: String", - link.imported_version_number AS "imported_version_number?: String", - link.imported_filename AS "imported_filename?: String", - COALESCE(( - SELECT json_group_array(group_name) - FROM ( - SELECT group_name - FROM instance_groups - WHERE instance_id = i.id - ORDER BY group_name - ) - ), '[]') AS "groups!: String", - json(overrides.overrides) AS "launch_overrides?: String" - FROM instances i - LEFT JOIN instance_content_sets cs - ON cs.id = i.applied_content_set_id - AND cs.instance_id = i.id - LEFT JOIN instance_links link - ON link.instance_id = i.id - LEFT JOIN instance_launch_overrides overrides - ON overrides.instance_id = i.id - WHERE i.id = ? - "#, - id, - ) - .fetch_optional(pool) - .await?; + let row = + query_instance_metadata!("", "FROM instances i", "WHERE i.id = ?", id,) + .fetch_optional(pool) + .await?; row.map(InstanceMetadataRow::into_record).transpose() } @@ -500,73 +626,19 @@ pub(crate) async fn get_instance_metadata_many( } let ids_json = serde_json::to_string(ids)?; - let rows = sqlx::query_as!( - InstanceMetadataRow, + let rows = query_instance_metadata!( r#" WITH requested AS ( SELECT value AS id, key AS ord FROM json_each(?) ) - SELECT - i.id AS "id!: String", - i.path AS "path!: String", - i.applied_content_set_id AS "applied_content_set_id?: String", - i.install_stage AS "install_stage!: String", - i.launcher_feature_version AS "launcher_feature_version!: String", - i.update_channel AS "update_channel!: String", - i.name AS "name!: String", - i.icon_path AS "icon_path?: String", - i.created AS "created!: i64", - i.modified AS "modified!: i64", - i.last_played AS "last_played?: i64", - i.submitted_time_played AS "submitted_time_played!: i64", - i.recent_time_played AS "recent_time_played!: i64", - cs.id AS "content_set_id?: String", - cs.instance_id AS "content_set_instance_id?: String", - cs.name AS "content_set_name?: String", - cs.source_kind AS "content_set_source_kind?: String", - cs.status AS "content_set_status?: String", - cs.game_version AS "content_set_game_version?: String", - cs.protocol_version AS "content_set_protocol_version?: i64", - cs.loader AS "content_set_loader?: String", - cs.loader_version AS "content_set_loader_version?: String", - cs.created AS "content_set_created?: i64", - cs.modified AS "content_set_modified?: i64", - COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", - link.modrinth_project_id AS "modrinth_project_id?: String", - link.modrinth_version_id AS "modrinth_version_id?: String", - link.server_project_id AS "server_project_id?: String", - link.content_project_id AS "content_project_id?: String", - link.content_version_id AS "content_version_id?: String", - link.hosting_server_id AS "hosting_server_id?: String", - json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", - link.hosting_active_instance_id AS "hosting_active_instance_id?: String", - link.shared_instance_id AS "shared_instance_id?: String", - link.imported_name AS "imported_name?: String", - link.imported_version_number AS "imported_version_number?: String", - link.imported_filename AS "imported_filename?: String", - COALESCE(( - SELECT json_group_array(group_name) - FROM ( - SELECT group_name - FROM instance_groups - WHERE instance_id = i.id - ORDER BY group_name - ) - ), '[]') AS "groups!: String", - json(overrides.overrides) AS "launch_overrides?: String" + "#, + r#" FROM requested INNER JOIN instances i ON i.id = requested.id - LEFT JOIN instance_content_sets cs - ON cs.id = i.applied_content_set_id - AND cs.instance_id = i.id - LEFT JOIN instance_links link - ON link.instance_id = i.id - LEFT JOIN instance_launch_overrides overrides - ON overrides.instance_id = i.id - ORDER BY requested.ord "#, + "ORDER BY requested.ord", ids_json, ) .fetch_all(pool) @@ -580,69 +652,10 @@ pub(crate) async fn get_instance_metadata_many( pub(crate) async fn list_instance_metadata( pool: &SqlitePool, ) -> crate::Result> { - let rows = sqlx::query_as!( - InstanceMetadataRow, - r#" - SELECT - i.id AS "id!: String", - i.path AS "path!: String", - i.applied_content_set_id AS "applied_content_set_id?: String", - i.install_stage AS "install_stage!: String", - i.launcher_feature_version AS "launcher_feature_version!: String", - i.update_channel AS "update_channel!: String", - i.name AS "name!: String", - i.icon_path AS "icon_path?: String", - i.created AS "created!: i64", - i.modified AS "modified!: i64", - i.last_played AS "last_played?: i64", - i.submitted_time_played AS "submitted_time_played!: i64", - i.recent_time_played AS "recent_time_played!: i64", - cs.id AS "content_set_id?: String", - cs.instance_id AS "content_set_instance_id?: String", - cs.name AS "content_set_name?: String", - cs.source_kind AS "content_set_source_kind?: String", - cs.status AS "content_set_status?: String", - cs.game_version AS "content_set_game_version?: String", - cs.protocol_version AS "content_set_protocol_version?: i64", - cs.loader AS "content_set_loader?: String", - cs.loader_version AS "content_set_loader_version?: String", - cs.created AS "content_set_created?: i64", - cs.modified AS "content_set_modified?: i64", - COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", - link.modrinth_project_id AS "modrinth_project_id?: String", - link.modrinth_version_id AS "modrinth_version_id?: String", - link.server_project_id AS "server_project_id?: String", - link.content_project_id AS "content_project_id?: String", - link.content_version_id AS "content_version_id?: String", - link.hosting_server_id AS "hosting_server_id?: String", - json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", - link.hosting_active_instance_id AS "hosting_active_instance_id?: String", - link.shared_instance_id AS "shared_instance_id?: String", - link.imported_name AS "imported_name?: String", - link.imported_version_number AS "imported_version_number?: String", - link.imported_filename AS "imported_filename?: String", - COALESCE(( - SELECT json_group_array(group_name) - FROM ( - SELECT group_name - FROM instance_groups - WHERE instance_id = i.id - ORDER BY group_name - ) - ), '[]') AS "groups!: String", - json(overrides.overrides) AS "launch_overrides?: String" - FROM instances i - LEFT JOIN instance_content_sets cs - ON cs.id = i.applied_content_set_id - AND cs.instance_id = i.id - LEFT JOIN instance_links link - ON link.instance_id = i.id - LEFT JOIN instance_launch_overrides overrides - ON overrides.instance_id = i.id - "#, - ) - .fetch_all(pool) - .await?; + let rows = + query_instance_metadata!("", "FROM instances i", "WHERE 1 = ?", 1_i64,) + .fetch_all(pool) + .await?; rows.into_iter() .map(InstanceMetadataRow::into_record) @@ -653,59 +666,10 @@ pub(crate) async fn get_instance_launch_context( instance_id: &str, pool: &SqlitePool, ) -> crate::Result> { - let row = sqlx::query_as!( - InstanceMetadataRow, - r#" - SELECT - i.id AS "id!: String", - i.path AS "path!: String", - i.applied_content_set_id AS "applied_content_set_id?: String", - i.install_stage AS "install_stage!: String", - i.launcher_feature_version AS "launcher_feature_version!: String", - i.update_channel AS "update_channel!: String", - i.name AS "name!: String", - i.icon_path AS "icon_path?: String", - i.created AS "created!: i64", - i.modified AS "modified!: i64", - i.last_played AS "last_played?: i64", - i.submitted_time_played AS "submitted_time_played!: i64", - i.recent_time_played AS "recent_time_played!: i64", - cs.id AS "content_set_id?: String", - cs.instance_id AS "content_set_instance_id?: String", - cs.name AS "content_set_name?: String", - cs.source_kind AS "content_set_source_kind?: String", - cs.status AS "content_set_status?: String", - cs.game_version AS "content_set_game_version?: String", - cs.protocol_version AS "content_set_protocol_version?: i64", - cs.loader AS "content_set_loader?: String", - cs.loader_version AS "content_set_loader_version?: String", - cs.created AS "content_set_created?: i64", - cs.modified AS "content_set_modified?: i64", - COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String", - link.modrinth_project_id AS "modrinth_project_id?: String", - link.modrinth_version_id AS "modrinth_version_id?: String", - link.server_project_id AS "server_project_id?: String", - link.content_project_id AS "content_project_id?: String", - link.content_version_id AS "content_version_id?: String", - link.hosting_server_id AS "hosting_server_id?: String", - json(link.hosting_instance_ids) AS "hosting_instance_ids?: String", - link.hosting_active_instance_id AS "hosting_active_instance_id?: String", - link.shared_instance_id AS "shared_instance_id?: String", - link.imported_name AS "imported_name?: String", - link.imported_version_number AS "imported_version_number?: String", - link.imported_filename AS "imported_filename?: String", - '[]' AS "groups!: String", - json(overrides.overrides) AS "launch_overrides?: String" - FROM instances i - LEFT JOIN instance_content_sets cs - ON cs.id = i.applied_content_set_id - AND cs.instance_id = i.id - LEFT JOIN instance_links link - ON link.instance_id = i.id - LEFT JOIN instance_launch_overrides overrides - ON overrides.instance_id = i.id - WHERE i.id = ? - "#, + let row = query_instance_metadata!( + "", + "FROM instances i", + "WHERE i.id = ?", instance_id, ) .fetch_optional(pool) @@ -753,6 +717,9 @@ where json(hosting_instance_ids) AS "hosting_instance_ids?: String", hosting_active_instance_id, shared_instance_id, + shared_instance_role, + shared_instance_manager_id, + shared_instance_linked_user_id, imported_name, imported_version_number, imported_filename @@ -949,7 +916,6 @@ pub(crate) async fn upsert_instance_link( let hosting_instance_ids = columns.hosting_instance_ids.as_deref(); let hosting_active_instance_id = columns.hosting_active_instance_id.as_deref(); - let shared_instance_id = columns.shared_instance_id.as_deref(); let imported_name = columns.imported_name.as_deref(); let imported_version_number = columns.imported_version_number.as_deref(); let imported_filename = columns.imported_filename.as_deref(); @@ -967,12 +933,11 @@ pub(crate) async fn upsert_instance_link( hosting_server_id, hosting_instance_ids, hosting_active_instance_id, - shared_instance_id, imported_name, imported_version_number, imported_filename ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?) ON CONFLICT (instance_id) DO UPDATE SET link_kind = excluded.link_kind, modrinth_project_id = excluded.modrinth_project_id, @@ -983,7 +948,6 @@ pub(crate) async fn upsert_instance_link( hosting_server_id = excluded.hosting_server_id, hosting_instance_ids = excluded.hosting_instance_ids, hosting_active_instance_id = excluded.hosting_active_instance_id, - shared_instance_id = excluded.shared_instance_id, imported_name = excluded.imported_name, imported_version_number = excluded.imported_version_number, imported_filename = excluded.imported_filename @@ -998,7 +962,6 @@ pub(crate) async fn upsert_instance_link( hosting_server_id, hosting_instance_ids, hosting_active_instance_id, - shared_instance_id, imported_name, imported_version_number, imported_filename, @@ -1009,6 +972,64 @@ pub(crate) async fn upsert_instance_link( Ok(()) } +pub(crate) async fn set_shared_instance_attachment( + instance_id: &str, + attachment: Option<&SharedInstanceAttachment>, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let shared_instance_id = attachment.map(|value| value.id.to_string()); + let shared_instance_role = + attachment.map(|value| value.role.as_str().to_string()); + let shared_instance_manager_id = + attachment.and_then(|value| value.manager_id.as_deref()); + let shared_instance_linked_user_id = + attachment.and_then(|value| value.linked_user_id.as_deref()); + let shared_instance_server_manager_name = + attachment.and_then(|value| value.server_manager_name.as_deref()); + let shared_instance_server_manager_icon_url = + attachment.and_then(|value| value.server_manager_icon_url.as_deref()); + + sqlx::query!( + " + INSERT INTO instance_links ( + instance_id, + link_kind, + shared_instance_id, + shared_instance_role, + shared_instance_manager_id, + shared_instance_server_manager_name, + shared_instance_server_manager_icon_url, + shared_instance_linked_user_id + ) + VALUES (?, 'unmanaged', ?, ?, ?, ?, ?, ?) + ON CONFLICT (instance_id) DO UPDATE SET + link_kind = CASE + WHEN excluded.shared_instance_id IS NULL + AND instance_links.link_kind = 'shared_instance' + THEN 'unmanaged' + ELSE instance_links.link_kind + END, + shared_instance_id = excluded.shared_instance_id, + shared_instance_role = excluded.shared_instance_role, + shared_instance_manager_id = excluded.shared_instance_manager_id, + shared_instance_server_manager_name = excluded.shared_instance_server_manager_name, + shared_instance_server_manager_icon_url = excluded.shared_instance_server_manager_icon_url, + shared_instance_linked_user_id = excluded.shared_instance_linked_user_id + ", + instance_id, + shared_instance_id, + shared_instance_role, + shared_instance_manager_id, + shared_instance_server_manager_name, + shared_instance_server_manager_icon_url, + shared_instance_linked_user_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + pub(crate) async fn replace_instance_groups( instance_id: &str, groups: &[String], @@ -1094,7 +1115,6 @@ struct InstanceLinkColumns { hosting_server_id: Option, hosting_instance_ids: Option, hosting_active_instance_id: Option, - shared_instance_id: Option, imported_name: Option, imported_version_number: Option, imported_filename: Option, @@ -1114,7 +1134,6 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1132,7 +1151,6 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1147,7 +1165,6 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1166,7 +1183,6 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1186,7 +1202,6 @@ fn instance_link_columns( hosting_instance_ids: Some(serde_json::to_string(instance_ids)?), hosting_active_instance_id: active_instance_id .map(|value| value.to_string()), - shared_instance_id: None, imported_name: None, imported_version_number: None, imported_filename: None, @@ -1207,28 +1222,27 @@ fn instance_link_columns( hosting_server_id: None, hosting_instance_ids: None, hosting_active_instance_id: None, - shared_instance_id: None, imported_name: name.clone(), imported_version_number: version_number.clone(), imported_filename: filename.clone(), }), - InstanceLink::SharedInstance { shared_instance_id } => { - Ok(InstanceLinkColumns { - link_kind: "shared_instance", - modrinth_project_id: None, - modrinth_version_id: None, - server_project_id: None, - content_project_id: None, - content_version_id: None, - hosting_server_id: None, - hosting_instance_ids: None, - hosting_active_instance_id: None, - shared_instance_id: Some(shared_instance_id.to_string()), - imported_name: None, - imported_version_number: None, - imported_filename: None, - }) - } + InstanceLink::SharedInstance { + modpack_project_id, + modpack_version_id, + } => Ok(InstanceLinkColumns { + link_kind: "shared_instance", + modrinth_project_id: modpack_project_id.clone(), + modrinth_version_id: modpack_version_id.clone(), + server_project_id: None, + content_project_id: None, + content_version_id: None, + hosting_server_id: None, + hosting_instance_ids: None, + hosting_active_instance_id: None, + imported_name: None, + imported_version_number: None, + imported_filename: None, + }), } } @@ -1273,6 +1287,60 @@ fn launch_overrides_from_json( } } +fn shared_instance_attachment( + shared_instance_id: Option, + shared_instance_role: Option, + shared_instance_manager_id: Option, + shared_instance_server_manager_name: Option, + shared_instance_server_manager_icon_url: Option, + shared_instance_linked_user_id: Option, + shared_sync_status: Option, + applied_update_id: Option, + latest_available_update_id: Option, +) -> crate::Result> { + let Some(id) = shared_instance_id else { + return Ok(None); + }; + + let role = match shared_instance_role { + Some(role) => SharedInstanceRole::from_stored_str(&role)?, + None => SharedInstanceRole::Member, + }; + let status = match shared_sync_status { + Some(status) => ContentSetSyncStatus::from_str(&status)?, + None => ContentSetSyncStatus::Unknown, + }; + + Ok(Some(SharedInstanceAttachment { + id, + role, + manager_id: shared_instance_manager_id, + server_manager_name: shared_instance_server_manager_name, + server_manager_icon_url: shared_instance_server_manager_icon_url, + linked_user_id: shared_instance_linked_user_id, + status, + applied_version: optional_i32(applied_update_id, "applied_update_id")?, + latest_version: optional_i32( + latest_available_update_id, + "latest_available_update_id", + )?, + })) +} + +fn optional_i32( + value: Option, + column: &str, +) -> crate::Result> { + value + .map(|value| { + value.parse().map_err(|err| { + crate::ErrorKind::InputError(format!("Invalid {column}: {err}")) + .into() + }) + }) + .transpose() +} + fn parse_uuid(value: Option, column: &str) -> crate::Result { let value = required(value, column)?; diff --git a/packages/app-lib/src/state/instances/commands/apply_content_install.rs b/packages/app-lib/src/state/instances/commands/apply_content_install.rs index 49536fb69..c3e036d53 100644 --- a/packages/app-lib/src/state/instances/commands/apply_content_install.rs +++ b/packages/app-lib/src/state/instances/commands/apply_content_install.rs @@ -502,6 +502,13 @@ pub(crate) async fn add_project_bytes( version_id: Option<&str>, state: &State, ) -> crate::Result { + if !path_util::is_safe_file_name(file_name) { + return Err(crate::ErrorKind::InputError(format!( + "Project file {file_name} has an invalid file name" + )) + .into()); + } + let _content_lock = state.lock_instance_content(instance_id).await; let scope = resolve_content_scope(instance_id, None, state).await?; let project_type = match project_type { @@ -561,6 +568,7 @@ pub(crate) async fn add_project_bytes( ) .await?; tx.commit().await?; + super::mark_shared_instance_stale(instance_id, &state.pool).await?; Ok(relative_path) } @@ -608,6 +616,7 @@ pub(crate) async fn record_project_file( ) .await?; tx.commit().await?; + super::mark_shared_instance_stale(instance_id, &state.pool).await?; Ok(()) } @@ -711,6 +720,8 @@ pub(crate) async fn toggle_disable_project( } tx.commit().await?; + super::mark_shared_instance_stale(instance_id, &state.pool).await?; + Ok(new_path) } @@ -752,9 +763,36 @@ pub(crate) async fn remove_project( tx.commit().await?; } + super::mark_shared_instance_stale(instance_id, &state.pool).await?; + Ok(()) } +pub(crate) async fn content_source_kind_for_project_path( + instance_id: &str, + project_path: &str, + state: &State, +) -> crate::Result> { + let scope = resolve_content_scope(instance_id, None, state).await?; + let Some(file) = content_rows::get_instance_file_by_relative_path( + &scope.instance.id, + project_path, + &state.pool, + ) + .await? + else { + return Ok(None); + }; + let entries = + content_rows::get_content_entries(&scope.content_set_id, &state.pool) + .await?; + + Ok(entries.into_iter().find_map(|entry| { + (entry.file_id.as_deref() == Some(file.id.as_str())) + .then_some(entry.source_kind) + })) +} + pub(crate) async fn rename_project_companion_file( instance_id: &str, old_project_path: &str, diff --git a/packages/app-lib/src/state/instances/commands/apply_content_update.rs b/packages/app-lib/src/state/instances/commands/apply_content_update.rs index 09aa35892..232e36130 100644 --- a/packages/app-lib/src/state/instances/commands/apply_content_update.rs +++ b/packages/app-lib/src/state/instances/commands/apply_content_update.rs @@ -52,6 +52,7 @@ struct InstalledProject { relative_path: String, project_id: Option, version_id: Option, + source_kind: ContentSourceKind, enabled: bool, } @@ -295,8 +296,14 @@ async fn plan_bulk_update( instance_id: &str, state: &State, ) -> crate::Result { - let updateable_paths = - bulk_updateable_project_paths(instance_id, state).await?; + let shared_instance_member = + is_shared_instance_member(instance_id, state).await?; + let updateable_paths = bulk_updateable_project_paths( + instance_id, + shared_instance_member, + state, + ) + .await?; if updateable_paths.is_empty() { return Ok(BulkUpdatePlan { project_updates: Vec::new(), @@ -330,6 +337,30 @@ async fn plan_bulk_update( })?; let installed = installed_projects(instance_id, &content_set, state).await?; + let updateable_paths = if shared_instance_member { + let managed_paths = installed + .iter() + .filter(|project| project.source_kind.is_shared_instance_managed()) + .map(|project| project.relative_path.clone()) + .collect::>(); + + updateable_paths + .into_iter() + .filter(|path| !managed_paths.contains(path)) + .collect::>() + } else { + updateable_paths + }; + let updates = updates + .into_iter() + .filter(|update| updateable_paths.contains(&update.relative_path)) + .collect::>(); + if updates.is_empty() { + return Ok(BulkUpdatePlan { + project_updates: Vec::new(), + dependency_additions: Vec::new(), + }); + } let installed_by_project = installed .iter() .filter_map(|project| { @@ -416,6 +447,7 @@ async fn plan_bulk_update( async fn bulk_updateable_project_paths( instance_id: &str, + shared_instance_member: bool, state: &State, ) -> crate::Result> { let items = super::list_content::list_content( @@ -426,7 +458,16 @@ async fn bulk_updateable_project_paths( ) .await?; - Ok(items.into_iter().map(|item| item.file_path).collect()) + Ok(items + .into_iter() + .filter(|item| { + !shared_instance_member + || !item + .source_kind + .is_some_and(ContentSourceKind::is_shared_instance_managed) + }) + .map(|item| item.file_path) + .collect()) } async fn installed_projects( @@ -471,10 +512,27 @@ fn installed_project_from_row( relative_path: file.relative_path.clone(), project_id: entry.project_id.clone(), version_id: entry.version_id.clone(), + source_kind: entry.source_kind, enabled: entry.enabled && file.enabled, }) } +async fn is_shared_instance_member( + instance_id: &str, + state: &State, +) -> crate::Result { + let Some(metadata) = + instance_rows::get_instance_metadata_by_id(instance_id, &state.pool) + .await? + else { + return Ok(false); + }; + + Ok(metadata + .shared_instance + .is_some_and(|attachment| attachment.role.is_member())) +} + async fn dependency_closure( root_versions: Vec, content_set: &ContentSet, diff --git a/packages/app-lib/src/state/instances/commands/edit_instance.rs b/packages/app-lib/src/state/instances/commands/edit_instance.rs index f57eb70d4..11a8b6ec6 100644 --- a/packages/app-lib/src/state/instances/commands/edit_instance.rs +++ b/packages/app-lib/src/state/instances/commands/edit_instance.rs @@ -102,11 +102,28 @@ pub(crate) async fn edit_instance( patch: EditInstance, pool: &SqlitePool, ) -> crate::Result { + let modifies_content = + patch.link.is_some() || patch.content_set_patch.is_some(); + let should_mark_shared_instance_stale = patch.link.is_some() + || patch.content_set_patch.as_ref().is_some_and(|patch| { + patch.source_kind.is_some() + || patch.game_version.is_some() + || patch.loader.is_some() + || patch.loader_version.is_some() + }); let mut instance = instance_rows::get_instance_by_id(instance_id, pool) .await? .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; + if modifies_content + && instance_rows::is_instance_quarantined(instance_id, pool).await? + { + return Err(crate::ErrorKind::InputError( + "Content in quarantined instances cannot be changed.".to_string(), + ) + .into()); + } let now = Utc::now(); apply_instance_patch(&mut instance, &patch, now); @@ -169,6 +186,10 @@ pub(crate) async fn edit_instance( tx.commit().await?; + if should_mark_shared_instance_stale { + super::mark_shared_instance_stale(instance_id, pool).await?; + } + Ok(instance) } diff --git a/packages/app-lib/src/state/instances/commands/get_instance.rs b/packages/app-lib/src/state/instances/commands/get_instance.rs index 87be04dfe..b404290d8 100644 --- a/packages/app-lib/src/state/instances/commands/get_instance.rs +++ b/packages/app-lib/src/state/instances/commands/get_instance.rs @@ -1,6 +1,6 @@ use crate::state::instances::{ ContentSet, Instance, InstanceLaunchOverrides, InstanceLink, - adapters::sqlite::instance_rows, + SharedInstanceAttachment, adapters::sqlite::instance_rows, }; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; @@ -10,6 +10,9 @@ pub struct InstanceMetadata { pub instance: Instance, pub applied_content_set: ContentSet, pub link: InstanceLink, + pub shared_instance: Option, + #[serde(default)] + pub quarantined: bool, pub groups: Vec, pub launch_overrides: InstanceLaunchOverrides, } @@ -25,44 +28,62 @@ pub(crate) async fn get_instance_metadata( instance_id: &str, pool: &SqlitePool, ) -> crate::Result> { - Ok( - instance_rows::get_instance_metadata_by_id(instance_id, pool) - .await? - .map(Into::into), - ) + let Some(record) = + instance_rows::get_instance_metadata_by_id(instance_id, pool).await? + else { + return Ok(None); + }; + let quarantined = + instance_rows::is_instance_quarantined(instance_id, pool).await?; + + Ok(Some(instance_metadata(record, quarantined))) } pub(crate) async fn get_instances_metadata( instance_ids: &[&str], pool: &SqlitePool, ) -> crate::Result> { - Ok( - instance_rows::get_instance_metadata_many(instance_ids, pool) - .await? - .into_iter() - .map(Into::into) - .collect(), - ) + let records = + instance_rows::get_instance_metadata_many(instance_ids, pool).await?; + let quarantined_ids = + instance_rows::get_quarantined_instance_ids(pool).await?; + + Ok(records + .into_iter() + .map(|record| { + let quarantined = quarantined_ids.contains(&record.instance.id); + instance_metadata(record, quarantined) + }) + .collect()) } pub(crate) async fn list_instances( pool: &SqlitePool, ) -> crate::Result> { - Ok(instance_rows::list_instance_metadata(pool) - .await? + let records = instance_rows::list_instance_metadata(pool).await?; + let quarantined_ids = + instance_rows::get_quarantined_instance_ids(pool).await?; + + Ok(records .into_iter() - .map(Into::into) + .map(|record| { + let quarantined = quarantined_ids.contains(&record.instance.id); + instance_metadata(record, quarantined) + }) .collect()) } -impl From for InstanceMetadata { - fn from(record: instance_rows::InstanceMetadataRecord) -> Self { - Self { - instance: record.instance, - applied_content_set: record.applied_content_set, - link: record.link, - groups: record.groups, - launch_overrides: record.launch_overrides, - } +fn instance_metadata( + record: instance_rows::InstanceMetadataRecord, + quarantined: bool, +) -> InstanceMetadata { + InstanceMetadata { + instance: record.instance, + applied_content_set: record.applied_content_set, + link: record.link, + shared_instance: record.shared_instance, + quarantined, + groups: record.groups, + launch_overrides: record.launch_overrides, } } diff --git a/packages/app-lib/src/state/instances/commands/list_content.rs b/packages/app-lib/src/state/instances/commands/list_content.rs index 4272f2990..69c2d8646 100644 --- a/packages/app-lib/src/state/instances/commands/list_content.rs +++ b/packages/app-lib/src/state/instances/commands/list_content.rs @@ -528,6 +528,7 @@ pub(crate) async fn dependencies_to_content_items( has_update: false, update_version_id: None, date_added: None, + source_kind: None, }) }) .collect::>(); @@ -738,6 +739,7 @@ async fn content_projects_for_scope( size: file.size, metadata: file_metadata_from_entry_or_cache(entry, metadata), project_type, + source_kind: entry.map(|entry| entry.source_kind), }, ); } @@ -897,9 +899,13 @@ async fn content_files_to_content_items( date_published: Some(version.date_published.to_rfc3339()), }), owner, - has_update: file.update_version_id.is_some(), + has_update: file.update_version_id.is_some() + && !file.source_kind.is_some_and( + ContentSourceKind::is_shared_instance_managed, + ), update_version_id: file.update_version_id.clone(), date_added: modification_times[index].clone(), + source_kind: file.source_kind, } }) .collect::>(); @@ -1089,6 +1095,10 @@ fn linked_modpack_ids(link: &InstanceLink) -> Option<(String, String)> { version_id: Some(version_id), .. } => Some((project_id.clone(), version_id.clone())), + InstanceLink::SharedInstance { + modpack_project_id: Some(project_id), + modpack_version_id: Some(version_id), + } => Some((project_id.clone(), version_id.clone())), _ => None, } } @@ -1103,6 +1113,10 @@ fn linked_modpack_source_kind( InstanceLink::ServerProjectModpack { .. } => { Some(ContentSourceKind::ServerProject) } + InstanceLink::SharedInstance { + modpack_project_id: Some(_), + modpack_version_id: Some(_), + } => Some(ContentSourceKind::ModrinthModpack), _ => None, } } @@ -1348,12 +1362,7 @@ async fn get_modpack_identifiers( } fn project_type_from_api_name(project_type: &str) -> ProjectType { - match project_type { - "resourcepack" => ProjectType::ResourcePack, - "shader" => ProjectType::ShaderPack, - "datapack" => ProjectType::DataPack, - _ => ProjectType::Mod, - } + ProjectType::from_name(project_type).unwrap_or(ProjectType::Mod) } fn sort_content_items(items: &mut [ContentItem]) { diff --git a/packages/app-lib/src/state/instances/commands/mod.rs b/packages/app-lib/src/state/instances/commands/mod.rs index bba272e0b..ff79b14d2 100644 --- a/packages/app-lib/src/state/instances/commands/mod.rs +++ b/packages/app-lib/src/state/instances/commands/mod.rs @@ -41,3 +41,9 @@ mod check_content_updates; mod apply_content_update; pub(crate) use self::apply_content_update::*; + +mod shared_instance; +pub(crate) use self::shared_instance::{ + attach_shared_instance, clear_shared_instance, mark_shared_instance_stale, + quarantine_shared_instance, set_shared_instance_sync_status, +}; diff --git a/packages/app-lib/src/state/instances/commands/shared_instance.rs b/packages/app-lib/src/state/instances/commands/shared_instance.rs new file mode 100644 index 000000000..d619b68f8 --- /dev/null +++ b/packages/app-lib/src/state/instances/commands/shared_instance.rs @@ -0,0 +1,184 @@ +use crate::state::instances::adapters::sqlite::{content_rows, instance_rows}; +use crate::state::instances::{ + ContentSetRemoteRef, ContentSetRemoteRefType, ContentSetSyncProvider, + ContentSetSyncState, ContentSetSyncStatus, InstanceLink, + SharedInstanceAttachment, SharedInstanceAttachmentInput, + SharedInstanceRole, +}; +use chrono::Utc; +use sqlx::SqlitePool; + +pub(crate) async fn attach_shared_instance( + instance_id: &str, + input: SharedInstanceAttachmentInput, + pool: &SqlitePool, +) -> crate::Result<()> { + let metadata = + instance_rows::get_instance_metadata_by_id(instance_id, pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let content_set_id = metadata.applied_content_set.id; + let attachment = SharedInstanceAttachment::from(input); + let sync_state = + shared_sync_state(&content_set_id, &attachment, Some(Utc::now())); + + let mut tx = pool.begin().await?; + instance_rows::set_shared_instance_attachment( + instance_id, + Some(&attachment), + &mut tx, + ) + .await?; + content_rows::upsert_content_set_remote_ref( + &ContentSetRemoteRef { + content_set_id: content_set_id.clone(), + ref_type: ContentSetRemoteRefType::SharedContentSet, + ref_id: attachment.id.clone(), + }, + &mut tx, + ) + .await?; + content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?; + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn clear_shared_instance( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + detach_shared_instance(instance_id, false, pool).await +} + +pub(crate) async fn quarantine_shared_instance( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + detach_shared_instance(instance_id, true, pool).await +} + +async fn detach_shared_instance( + instance_id: &str, + quarantine: bool, + pool: &SqlitePool, +) -> crate::Result<()> { + let Some(metadata) = + instance_rows::get_instance_metadata_by_id(instance_id, pool).await? + else { + return Ok(()); + }; + let content_set_id = metadata.applied_content_set.id; + let retained_modpack_link = match metadata.link { + InstanceLink::SharedInstance { + modpack_project_id: Some(project_id), + modpack_version_id: Some(version_id), + } => Some(InstanceLink::ModrinthModpack { + project_id, + version_id, + }), + _ => None, + }; + + let mut tx = pool.begin().await?; + instance_rows::set_shared_instance_attachment(instance_id, None, &mut tx) + .await?; + if quarantine { + instance_rows::set_instance_quarantined(instance_id, true, &mut tx) + .await?; + } + if let Some(link) = retained_modpack_link.as_ref() { + instance_rows::upsert_instance_link(instance_id, link, &mut tx).await?; + } + content_rows::delete_content_set_remote_ref( + &content_set_id, + ContentSetRemoteRefType::SharedContentSet, + &mut tx, + ) + .await?; + content_rows::delete_content_set_sync_state(&content_set_id, &mut tx) + .await?; + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn set_shared_instance_sync_status( + instance_id: &str, + status: ContentSetSyncStatus, + applied_version: Option, + latest_version: Option, + pool: &SqlitePool, +) -> crate::Result<()> { + let metadata = + instance_rows::get_instance_metadata_by_id(instance_id, pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let Some(mut attachment) = metadata.shared_instance else { + return Ok(()); + }; + + attachment.status = status; + attachment.applied_version = applied_version; + attachment.latest_version = latest_version; + + let sync_state = shared_sync_state( + &metadata.applied_content_set.id, + &attachment, + Some(Utc::now()), + ); + let mut tx = pool.begin().await?; + content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?; + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn mark_shared_instance_stale( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + let Some(metadata) = + instance_rows::get_instance_metadata_by_id(instance_id, pool).await? + else { + return Ok(()); + }; + let Some(attachment) = metadata.shared_instance else { + return Ok(()); + }; + if attachment.role != SharedInstanceRole::Owner { + return Ok(()); + } + + set_shared_instance_sync_status( + instance_id, + ContentSetSyncStatus::Stale, + attachment.applied_version, + attachment.latest_version, + pool, + ) + .await +} + +fn shared_sync_state( + content_set_id: &str, + attachment: &SharedInstanceAttachment, + checked_at: Option>, +) -> ContentSetSyncState { + ContentSetSyncState { + content_set_id: content_set_id.to_string(), + provider: ContentSetSyncProvider::SharedInstance, + applied_update_id: attachment + .applied_version + .map(|value| value.to_string()), + latest_available_update_id: attachment + .latest_version + .map(|value| value.to_string()), + checked_at, + status: attachment.status, + } +} diff --git a/packages/app-lib/src/state/instances/commands/sync_content_files.rs b/packages/app-lib/src/state/instances/commands/sync_content_files.rs index 79af5ab59..a3b6ae915 100644 --- a/packages/app-lib/src/state/instances/commands/sync_content_files.rs +++ b/packages/app-lib/src/state/instances/commands/sync_content_files.rs @@ -75,6 +75,7 @@ pub(crate) async fn sync_instance_content_files( let now = Utc::now(); let mut files = Vec::new(); let mut present_without_hash_ids = Vec::new(); + let mut restored_without_hash = false; for file in scanned { let hash_key = file.hash_cache_key.trim_end_matches(".disabled"); @@ -82,6 +83,7 @@ pub(crate) async fn sync_instance_content_files( let Some(hash) = hashes_by_key.get(hash_key) else { if let Some(existing_file) = existing_file { present_without_hash_ids.push(existing_file.id.clone()); + restored_without_hash |= existing_file.missing; } continue; }; @@ -102,6 +104,19 @@ pub(crate) async fn sync_instance_content_files( }); } + let content_changed = !missing_file_ids.is_empty() + || restored_without_hash + || files.iter().any(|file| { + existing_files_by_path.get(&file.relative_path).is_none_or( + |existing| { + existing.missing + || existing.enabled != file.enabled + || existing.sha1 != file.sha1 + || existing.size != file.size + }, + ) + }); + let mut tx = state.pool.begin().await?; for file_id in missing_file_ids { sqlite::content_rows::set_instance_file_missing( @@ -129,6 +144,10 @@ pub(crate) async fn sync_instance_content_files( tx.commit().await?; + if content_changed { + super::mark_shared_instance_stale(&instance.id, &state.pool).await?; + } + Ok(stored_files) } diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs index 507d92092..6a0838ab6 100644 --- a/packages/app-lib/src/state/instances/content.rs +++ b/packages/app-lib/src/state/instances/content.rs @@ -1,3 +1,4 @@ +use super::ContentSourceKind; use crate::state::{Project, ProjectType, Version}; use serde::{Deserialize, Serialize}; @@ -15,6 +16,7 @@ pub struct ContentItem { pub has_update: bool, pub update_version_id: Option, pub date_added: Option, + pub source_kind: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/packages/app-lib/src/state/instances/mod.rs b/packages/app-lib/src/state/instances/mod.rs index 9426b01d5..4a9195c1f 100644 --- a/packages/app-lib/src/state/instances/mod.rs +++ b/packages/app-lib/src/state/instances/mod.rs @@ -10,6 +10,10 @@ pub use self::commands::{ AppliedContentSetPatch, CreateInstance, EditInstance, InstanceLaunchOverridesPatch, InstanceMetadata, }; +pub(crate) use self::commands::{ + attach_shared_instance, clear_shared_instance, mark_shared_instance_stale, + quarantine_shared_instance, set_shared_instance_sync_status, +}; pub(crate) use self::commands::{ create_instance, edit_instance, get_instance, get_instances_metadata, list_instances, refresh_all_instances, remove_instance, diff --git a/packages/app-lib/src/state/instances/model/content_set.rs b/packages/app-lib/src/state/instances/model/content_set.rs index 7fb62ea09..24e6f826d 100644 --- a/packages/app-lib/src/state/instances/model/content_set.rs +++ b/packages/app-lib/src/state/instances/model/content_set.rs @@ -16,6 +16,15 @@ pub enum ContentSourceKind { } impl ContentSourceKind { + pub fn is_shared_instance_managed(self) -> bool { + matches!( + self, + Self::SharedInstance + | Self::ModrinthModpack + | Self::ImportedModpack + ) + } + pub fn as_str(self) -> &'static str { match self { Self::Local => "local", diff --git a/packages/app-lib/src/state/instances/model/link.rs b/packages/app-lib/src/state/instances/model/link.rs index e85ed0860..0ae8e26e2 100644 --- a/packages/app-lib/src/state/instances/model/link.rs +++ b/packages/app-lib/src/state/instances/model/link.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use super::ContentSetSyncStatus; + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InstanceLink { @@ -32,7 +34,85 @@ pub enum InstanceLink { version_number: Option, filename: Option, }, + /// Modpack provenance installed by a shared instance. Remote membership, + /// manager identity, and synchronization state belong to + /// [`SharedInstanceAttachment`]. SharedInstance { - shared_instance_id: Uuid, + modpack_project_id: Option, + modpack_version_id: Option, }, } + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SharedInstanceRole { + Owner, + Member, +} + +impl SharedInstanceRole { + pub fn is_member(self) -> bool { + self == Self::Member + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Owner => "owner", + Self::Member => "member", + } + } + + pub fn from_stored_str(value: &str) -> crate::Result { + match value { + "owner" => Ok(Self::Owner), + "member" => Ok(Self::Member), + other => Err(super::unknown_value("shared instance role", other)), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +/// The remote shared-instance relationship for a local instance. This is +/// independent from the optional modpack provenance stored in [`InstanceLink`]. +pub struct SharedInstanceAttachment { + pub id: String, + pub role: SharedInstanceRole, + pub manager_id: Option, + #[serde(default)] + pub server_manager_name: Option, + #[serde(default)] + pub server_manager_icon_url: Option, + pub linked_user_id: Option, + pub status: ContentSetSyncStatus, + pub applied_version: Option, + pub latest_version: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct SharedInstanceAttachmentInput { + pub id: String, + pub role: SharedInstanceRole, + pub manager_id: Option, + pub server_manager_name: Option, + pub server_manager_icon_url: Option, + pub linked_user_id: Option, + pub status: ContentSetSyncStatus, + pub applied_version: Option, + pub latest_version: Option, +} + +impl From for SharedInstanceAttachment { + fn from(input: SharedInstanceAttachmentInput) -> Self { + Self { + id: input.id, + role: input.role, + manager_id: input.manager_id, + server_manager_name: input.server_manager_name, + server_manager_icon_url: input.server_manager_icon_url, + linked_user_id: input.linked_user_id, + status: input.status, + applied_version: input.applied_version, + latest_version: input.latest_version, + } + } +} diff --git a/packages/app-lib/src/state/instances/watcher.rs b/packages/app-lib/src/state/instances/watcher.rs index 08647a5dc..b7e6dd86c 100644 --- a/packages/app-lib/src/state/instances/watcher.rs +++ b/packages/app-lib/src/state/instances/watcher.rs @@ -1,4 +1,5 @@ use crate::State; +use crate::api::instance::CONFIG_DIRECTORY; use crate::event::InstancePayloadType; use crate::event::emit::{emit_instance, emit_warning}; use crate::state::{ @@ -128,17 +129,41 @@ pub async fn init_watcher() -> crate::Result { Some(InstancePayloadType::WorldUpdated { world, }) - } else if first_file_name - .as_ref() - .is_none_or(|x| *x != "saves") - { + } else if first_file_name.as_ref().is_none_or( + |x| *x != "saves" && *x != CONFIG_DIRECTORY, + ) { Some(InstancePayloadType::Synced) } else { None }; if let Some(event) = event { let emit_instance_id = instance_id.clone(); + let mark_shared_stale = first_file_name + .as_ref() + .is_some_and(|name| { + ProjectType::iterator().any( + |project_type| { + *name + == project_type + .get_folder() + }, + ) + }); tokio::spawn(async move { + if mark_shared_stale + && let Ok(state) = + State::get().await + && let Err(error) = + crate::state::mark_shared_instance_stale( + &emit_instance_id, + &state.pool, + ) + .await + { + tracing::error!( + "Failed to mark shared instance stale after filesystem sync: {error}" + ); + } let _ = emit_instance( &emit_instance_id, event, @@ -194,10 +219,11 @@ pub(crate) async fn watch_instance_folder( } let mut to_watch = Vec::new(); - for sub_path in ProjectType::iterator() - .map(|x| x.get_folder()) - .chain(["crash-reports", "saves"]) - { + for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([ + "crash-reports", + "saves", + CONFIG_DIRECTORY, + ]) { let full_path = full_instance_path.join(sub_path); let meta = tokio::fs::symlink_metadata(&full_path).await; diff --git a/packages/app-lib/src/state/mr_auth.rs b/packages/app-lib/src/state/mr_auth.rs index 09b735ea5..54276bf56 100644 --- a/packages/app-lib/src/state/mr_auth.rs +++ b/packages/app-lib/src/state/mr_auth.rs @@ -195,6 +195,10 @@ pub const fn get_login_url() -> &'static str { concat!(env!("MODRINTH_URL"), "auth/sign-in") } +pub const fn get_signup_url() -> &'static str { + concat!(env!("MODRINTH_URL"), "auth/sign-up") +} + pub async fn finish_login_flow( code: &str, semaphore: &FetchSemaphore, diff --git a/packages/app-lib/src/util/fetch.rs b/packages/app-lib/src/util/fetch.rs index 0662a3d5c..fbca82414 100644 --- a/packages/app-lib/src/util/fetch.rs +++ b/packages/app-lib/src/util/fetch.rs @@ -915,13 +915,10 @@ pub async fn write_cached_icon( bytes: Bytes, semaphore: &IoSemaphore, ) -> crate::Result { - let extension = Path::new(&icon_path).extension().and_then(OsStr::to_str); let hash = sha1_async(bytes.clone()).await?; - let path = cache_dir.join("icons").join(if let Some(ext) = extension { - format!("{hash}.{ext}") - } else { - hash - }); + let path = cache_dir + .join("icons") + .join(cached_icon_file_name(icon_path, &hash)); write(&path, &bytes, semaphore).await?; @@ -929,6 +926,14 @@ pub async fn write_cached_icon( Ok(path) } +fn cached_icon_file_name(icon_path: &str, hash: &str) -> String { + let path = icon_path.split(['?', '#']).next().unwrap_or(icon_path); + match Path::new(path).extension().and_then(OsStr::to_str) { + Some(extension) => format!("{hash}.{extension}"), + None => hash.to_string(), + } +} + pub async fn sha1_async(bytes: Bytes) -> crate::Result { let hash = tokio::task::spawn_blocking(move || { sha1_smol::Sha1::from(bytes).hexdigest() diff --git a/packages/assets/branding/illustrations/invite-bg.webp b/packages/assets/branding/illustrations/invite-bg.webp new file mode 100644 index 0000000000000000000000000000000000000000..3502dd8d336a1e3d9d76acf34add8a28674bf3bf GIT binary patch literal 83234 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�&+= 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 + + + + + + + + + + + + + diff --git a/packages/assets/index.ts b/packages/assets/index.ts index b7ab443c7..1eb049970 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -11,6 +11,7 @@ import './omorphia.scss' import _FourOhFourNotFound from './branding/404.svg?component' // Branding import _BrowserWindowSuccessIllustration from './branding/illustrations/browser-window-success.svg?component' +import _InviteBackgroundIllustration from './branding/illustrations/invite-bg.webp?url' import _ModrinthIcon from './branding/logo.svg?component' import _ModrinthPlusIcon from './branding/modrinth-plus.svg?component' import _AngryRinthbot from './branding/rinthbot/angry.webp' @@ -84,6 +85,7 @@ import _NoTasksIllustration from './illustrations/no-tasks.svg?component' export const ModrinthIcon = _ModrinthIcon export const BrowserWindowSuccessIllustration = _BrowserWindowSuccessIllustration +export const InviteBackgroundIllustration = _InviteBackgroundIllustration export const FourOhFourNotFound = _FourOhFourNotFound export const ModrinthPlusIcon = _ModrinthPlusIcon export const AngryRinthbot = _AngryRinthbot diff --git a/packages/moderation/src/types/reports.ts b/packages/moderation/src/types/reports.ts index 249e457f8..e83b72570 100644 --- a/packages/moderation/src/types/reports.ts +++ b/packages/moderation/src/types/reports.ts @@ -1,5 +1,5 @@ -import type { Labrinth } from '@modrinth/api-client' -import type { Report, Thread, User, Version } from '@modrinth/utils' +import type { Labrinth, SharedInstances } from '@modrinth/api-client' +import type { Thread, User, Version } from '@modrinth/utils' export interface OwnershipTarget { name: string @@ -8,11 +8,12 @@ export interface OwnershipTarget { type: 'user' | 'organization' } -export interface ExtendedReport extends Report { +export interface ExtendedReport extends Labrinth.Reports.v3.Report { thread: Thread reporter_user: User project?: Labrinth.Projects.v2.Project user?: User version?: Version target?: OwnershipTarget + shared_instance?: SharedInstances.Instances.v1.Instance } diff --git a/packages/path-util/src/lib.rs b/packages/path-util/src/lib.rs index eafa733c0..2d8605d88 100644 --- a/packages/path-util/src/lib.rs +++ b/packages/path-util/src/lib.rs @@ -13,6 +13,20 @@ use typed_path::{ #[repr(transparent)] pub struct SafeRelativeUtf8UnixPathBuf(Utf8UnixPathBuf); +pub fn is_safe_file_name(file_name: &str) -> bool { + if file_name.contains('/') || file_name.contains('\\') { + return false; + } + + SafeRelativeUtf8UnixPathBuf::try_from(file_name.to_string()).is_ok_and( + |path| { + path.components() + .exactly_one() + .is_ok_and(|component| component.is_normal()) + }, + ) +} + impl<'de> Deserialize<'de> for SafeRelativeUtf8UnixPathBuf { fn deserialize>( deserializer: D, @@ -146,3 +160,32 @@ fn safe_relative_path_deserialization_contract() { .expect_err("Path should be considered invalid"); } } + +#[test] +fn safe_file_name_contract() { + let valid_file_names = [ + "file.txt", + "file.name.with.dots.tar.gz", + "file..name.jar", + "123_456-789.file", + ]; + for file_name in valid_file_names { + assert!(is_safe_file_name(file_name)); + } + + let invalid_file_names = [ + "", + ".", + "..", + "../file.txt", + "directory/file.txt", + r"..\file.txt", + r"directory\file.txt", + "C:file.txt", + "C:/file.txt", + "NUL.txt", + ]; + for file_name in invalid_file_names { + assert!(!is_safe_file_name(file_name)); + } +} diff --git a/packages/ui/src/components/base/Admonition.vue b/packages/ui/src/components/base/Admonition.vue index 38c09af89..26a3bfef6 100644 --- a/packages/ui/src/components/base/Admonition.vue +++ b/packages/ui/src/components/base/Admonition.vue @@ -9,25 +9,47 @@ -

+
- {{ header }} - - - {{ relativeTimeLabel }} - + {{ header }} + + + {{ relativeTimeLabel }} + +
+
+ {{ body }} +
-
- {{ body }} +
+
-
+
@@ -80,9 +102,10 @@ import ButtonStyled from './ButtonStyled.vue' const props = withDefaults( defineProps<{ - type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning' + type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning' | 'neutral' header?: string body?: string + inlineActions?: boolean showActionsUnderneath?: boolean dismissible?: boolean progress?: number @@ -95,6 +118,7 @@ const props = withDefaults( type: 'info', header: '', body: '', + inlineActions: false, showActionsUnderneath: false, dismissible: false, progress: undefined, @@ -143,6 +167,7 @@ const typeClasses = { critical: 'border-brand-red bg-bg-red', success: 'border-brand-green bg-bg-green', moderation: 'border-brand-orange bg-bg-orange', + neutral: 'border-surface-4 bg-surface-3', } const iconClasses = { @@ -152,6 +177,7 @@ const iconClasses = { critical: 'text-brand-red', success: 'text-brand-green', moderation: 'text-brand-orange', + neutral: 'text-secondary', } const buttonColors = { @@ -161,6 +187,7 @@ const buttonColors = { critical: 'red', success: 'green', moderation: 'orange', + neutral: 'standard', } as const const progressTrackClasses = { @@ -170,6 +197,7 @@ const progressTrackClasses = { critical: 'bg-brand-red/20', success: 'bg-brand-green/20', moderation: 'bg-brand-orange/20', + neutral: 'bg-surface-4', } const progressFillClasses = { @@ -181,10 +209,15 @@ const progressFillClasses = { blue: 'bg-brand-blue', green: 'bg-brand-green', red: 'bg-brand-red', + neutral: 'bg-surface-5', }