diff --git a/CLAUDE.md b/CLAUDE.md
index 7cb1dad1b6..ffd69006c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,10 +67,22 @@ Each project may have its own `CLAUDE.md` with detailed instructions:
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
+## Skills (`.claude/skills/`)
+
+Project-specific skill files with detailed patterns. Use them when the task matches:
+
+- **`api-module`** — Adding a new API endpoint module to `packages/api-client` (types, module class, registry registration)
+- **`cross-platform-pages`** — Building a page that needs to work in both the website (`apps/frontend`) and the desktop app (`apps/app-frontend`)
+- **`dependency-injection`** — Creating or wiring up a `provide`/`inject` context for platform abstraction or deep component state sharing
+- **`figma-mcp`** — Translating a Figma design into Vue components using the Figma MCP tools
+- **`i18n-convert`** — Converting hardcoded English strings in Vue SFCs into the `@modrinth/ui` i18n system (`defineMessages`, `formatMessage`, `IntlFormatted`)
+- **`multistage-modals`** — Building a wizard-like modal with multiple stages, progress tracking, and per-stage buttons using `MultiStageModal`
+- **`tanstack-query`** — Fetching, caching, or mutating server data with `@tanstack/vue-query` (queries, mutations, invalidation, optimistic updates)
+
## Code Guidelines
### Comments
-- DO NOT use "heading" comments like: // === Helper methods === .
+- DO NOT use "heading" comments like: `=== Helper methods ===`.
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
## Bash Guidelines
@@ -78,12 +90,14 @@ Each project may have its own `CLAUDE.md` with detailed instructions:
### Output handling
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
- NEVER use `| head -n X` or `| tail -n X` to truncate output
-- Run commands directly without pipes when possible
-- If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
+- IMPORTANT: Run commands directly without pipes when possible
+- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
- ALWAYS read the full output — never pipe through filters
### General
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
+- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
+- When editing, if the file has tabs USE TABS in edit tool - do not go back and forth identifying tabs in the file.
## Skills
diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json
index c7561a588b..d892df69f6 100644
--- a/apps/app-frontend/package.json
+++ b/apps/app-frontend/package.json
@@ -22,23 +22,24 @@
"@tanstack/vue-query": "^5.90.7",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
- "@tauri-apps/plugin-http": "^2.5.0",
+ "@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
- "intl-messageformat": "^10.7.7",
- "vue-i18n": "^10.0.0",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
+ "fuse.js": "^6.6.2",
+ "intl-messageformat": "^10.7.7",
"ofetch": "^1.3.4",
"pinia": "^3.0.0",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.13",
+ "vue-i18n": "^10.0.0",
"vue-multiselect": "3.0.0",
"vue-router": "^4.6.0",
"vue-virtual-scroller": "v2.0.0-beta.8"
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index eed52f8356..f7a029b5c3 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -31,6 +31,8 @@ import {
Button,
ButtonStyled,
commonMessages,
+ ContentInstallModal,
+ CreationFlowModal,
defineMessages,
I18nDebugPanel,
NewsArticleCard,
@@ -38,6 +40,7 @@ import {
OverflowMenu,
PopupNotificationPanel,
ProgressSpinner,
+ provideModalBehavior,
provideModrinthClient,
provideNotificationManager,
providePageContext,
@@ -66,8 +69,6 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
-import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
-import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
@@ -86,6 +87,7 @@ import { check_reachable } from '@/helpers/auth.js'
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
+import { create_profile_and_install_from_file } from '@/helpers/pack'
import { list } from '@/helpers/profile.js'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -98,15 +100,16 @@ import {
isNetworkMetered,
} from '@/helpers/utils.js'
import i18n from '@/i18n.config'
+import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import {
provideAppUpdateDownloadProgress,
subscribeToDownloadProgress,
} from '@/providers/download-progress.ts'
+import { createServerInstall, provideServerInstall } from '@/providers/server-install'
+import { setupProviders } from '@/providers/setup'
import { useError } from '@/store/error.js'
-import { playServerProject, useInstall } from '@/store/install.js'
import { useLoading, useTheming } from '@/store/state'
-import { create_profile_and_install_from_file } from './helpers/pack'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
@@ -136,6 +139,20 @@ providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
})
+provideModalBehavior({
+ noblur: computed(() => !themeStore.advancedRendering),
+ onShow: () => hide_ads_window(),
+ onHide: () => show_ads_window(),
+})
+
+const {
+ installationModal,
+ handleCreate,
+ handleBrowseModpacks,
+ searchModpacks,
+ getProjectVersions,
+} = setupProviders(notificationManager)
+
const news = ref([])
const availableSurvey = ref(false)
@@ -392,7 +409,34 @@ const error = useError()
const errorModal = ref()
const minecraftAuthErrorModal = ref()
-const install = useInstall()
+const contentInstall = createContentInstall({ router, handleError })
+provideContentInstall(contentInstall)
+const {
+ instances: contentInstallInstances,
+ compatibleLoaders: contentInstallLoaders,
+ gameVersions: contentInstallGameVersions,
+ loading: contentInstallLoading,
+ defaultTab: contentInstallDefaultTab,
+ preferredLoader: contentInstallPreferredLoader,
+ preferredGameVersion: contentInstallPreferredGameVersion,
+ releaseGameVersions: contentInstallReleaseGameVersions,
+ handleInstallToInstance,
+ handleCreateAndInstall,
+ handleNavigate: handleContentInstallNavigate,
+ handleCancel: handleContentInstallCancel,
+ setContentInstallModal,
+ setInstallConfirmModal: setContentInstallConfirmModal,
+ setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
+} = contentInstall
+
+const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
+provideServerInstall(serverInstall)
+const {
+ setInstallToPlayModal: setServerInstallToPlayModal,
+ setUpdateToPlayModal: setServerUpdateToPlayModal,
+ setAddServerToInstanceModal: setServerAddServerToInstanceModal,
+} = serverInstall
+
const modInstallModal = ref()
const addServerToInstanceModal = ref()
const installConfirmModal = ref()
@@ -474,13 +518,12 @@ onMounted(() => {
error.setErrorModal(errorModal.value)
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
- install.setIncompatibilityWarningModal(incompatibilityWarningModal)
- install.setInstallConfirmModal(installConfirmModal)
- install.setModInstallModal(modInstallModal)
- install.setAddServerToInstanceModal(addServerToInstanceModal)
- install.setInstallToPlayModal(installToPlayModal)
- install.setUpdateToPlayModal(updateToPlayModal)
- install.setPopupNotificationManager(popupNotificationManager)
+ setContentIncompatibilityWarningModal(incompatibilityWarningModal.value)
+ setContentInstallConfirmModal(installConfirmModal.value)
+ setContentInstallModal(modInstallModal.value)
+ setServerAddServerToInstanceModal(addServerToInstanceModal.value)
+ setServerInstallToPlayModal(installToPlayModal.value)
+ setServerUpdateToPlayModal(updateToPlayModal.value)
})
const accounts = ref(null)
@@ -898,9 +941,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
-
-
-
+
@@ -946,7 +995,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -1021,9 +1070,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
-
-
-
+
+
+
-
+
-
+
diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue
index a8e8970aed..56c65fa0b4 100644
--- a/apps/app-frontend/src/components/RowDisplay.vue
+++ b/apps/app-frontend/src/components/RowDisplay.vue
@@ -24,10 +24,11 @@ import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { showProfileInFolder } from '@/helpers/utils.js'
+import { injectContentInstall } from '@/providers/content-install'
import { handleSevereError } from '@/store/error.js'
-import { install as installVersion } from '@/store/install.js'
const { handleError } = injectNotificationManager()
+const { install: installVersion } = injectContentInstall()
const router = useRouter()
diff --git a/apps/app-frontend/src/components/ui/Breadcrumbs.vue b/apps/app-frontend/src/components/ui/Breadcrumbs.vue
index ee12e17ea7..144ed5ebc0 100644
--- a/apps/app-frontend/src/components/ui/Breadcrumbs.vue
+++ b/apps/app-frontend/src/components/ui/Breadcrumbs.vue
@@ -1,64 +1,147 @@
-
-
-
-
-
+
-
-
- {{ breadcrumbData.resetToNames(breadcrumbs) }}
-
- {{
- breadcrumb.name.charAt(0) === '?'
- ? breadcrumbData.getName(breadcrumb.name.slice(1))
- : breadcrumb.name
- }}
-
- {{
- breadcrumb.name.charAt(0) === '?'
- ? breadcrumbData.getName(breadcrumb.name.slice(1))
- : breadcrumb.name
- }}
-
-
+ {{ breadcrumbData.resetToNames(breadcrumbs) }}
+
+
+ {{ resolveLabel(breadcrumb.name) }}
+
+
+ {{ resolveLabel(breadcrumb.name) }}
+
+
+
+
-
+
+
diff --git a/apps/app-frontend/src/components/ui/ExportModal.vue b/apps/app-frontend/src/components/ui/ExportModal.vue
index 4966b1c7bd..fa605f3206 100644
--- a/apps/app-frontend/src/components/ui/ExportModal.vue
+++ b/apps/app-frontend/src/components/ui/ExportModal.vue
@@ -1,6 +1,14 @@
-
+
@@ -143,7 +178,7 @@ const exportPack = async () => {
- Select files and folders to include in pack
+ {{ formatMessage(messages.selectFilesLabel) }}
{
diff --git a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue b/apps/app-frontend/src/components/ui/InstanceCreationModal.vue
deleted file mode 100644
index b2920fbb5e..0000000000
--- a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue
+++ /dev/null
@@ -1,662 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Select icon
-
-
-
- Remove icon
-
-
-
-
-
-
-
-
-
-
-
-
Import from file
-
Or drag and drop your .mrpack file
-
-
-
-
-
{{ selectedProfileType.name }} path
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- profiles
- .get(selectedProfileType.name)
- ?.forEach((child) => (child.selected = newValue))
- "
- />
-
-
Profile name
-
-
-
-
-
-
-
- {{ profile.name }}
-
-
-
-
No profiles found
-
-
-
-
-
-
-
-
-
diff --git a/apps/app-frontend/src/components/ui/NavTabs.vue b/apps/app-frontend/src/components/ui/NavTabs.vue
index 1e5f88c2e6..743d0dbc4a 100644
--- a/apps/app-frontend/src/components/ui/NavTabs.vue
+++ b/apps/app-frontend/src/components/ui/NavTabs.vue
@@ -148,8 +148,9 @@ function startAnimation() {
}
}
-onMounted(() => {
+onMounted(async () => {
window.addEventListener('resize', pickLink)
+ await nextTick()
pickLink()
})
diff --git a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
index 442f424ec4..7fbc731fd6 100644
--- a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
+++ b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
@@ -49,26 +49,22 @@ onUnmounted(() => {
-
-
-
-
-
-
+
-
+
+
@@ -60,14 +61,17 @@
- repairProfile(true)"
- />
- {
- changingVersion = false
- modpackVersion =
- modpackVersions?.find(
- (version: Version) => version.id === props.instance.linked_data?.version_id,
- ) ?? null
- }
- "
- />
- unpairProfile()"
- />
- repairModpack()"
- />
-
-
-
- {{ formatMessage(messages.noConnection) }}
-
-
-
-
- {{ formatMessage(messages.noModpackFound) }}
-
-
{{ formatMessage(messages.debugInformation) }}
-
- {{ instance.linked_data }}
-
-
-
-
-
- {{ formatMessage(messages.fetchingModpackDetails) }}
-
-
-
-
-
-
-
-
-
-
- {{
- modpackProject
- ? modpackProject.title
- : formatMessage(messages.minecraftVersion, {
- version: instance.game_version,
- })
- }}
-
-
- {{
- modpackProject
- ? modpackVersion
- ? modpackVersion?.version_number
- : props.isMinecraftServer
- ? ''
- : 'Unknown version'
- : formatLoader(formatMessage, instance.loader)
- }}
-
- {{ instance.loader_version || formatMessage(messages.unknownVersion) }}
-
-
- {{ instance.loader }}
- {{ instance.loader_version }}
-
-
-
-
-
-
-
-
-
- {{
- repairing
- ? formatMessage(messages.repairingButton)
- : formatMessage(messages.repairButton)
- }}
-
-
-
- {
- changingVersion = true
- modpackVersionModal.show()
- }
- "
- >
-
-
- {{
- changingVersion
- ? formatMessage(messages.installingButton)
- : formatMessage(messages.changeVersionButton)
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{
- formatMessage(messages.noLoaderVersions, {
- loader: loader,
- version: gameVersion,
- })
- }}
-
-
-
-
-
-
-
- {{
- editing
- ? formatMessage(messages.installingButton)
- : formatMessage(messages.installButton)
- }}
-
-
-
- {
- loader = instance.loader
- gameVersion = instance.game_version
- resetLoaderVersionIndex()
- }
- "
- >
-
- {{ formatMessage(messages.resetSelections) }}
-
-
-
-
-
-
-
-
- {{
- formatMessage(
- props.isMinecraftServer
- ? instance.loader === 'vanilla'
- ? messages.unlinkServerVanillaDescription
- : messages.unlinkServerDescription
- : messages.unlinkInstanceDescription,
- )
- }}
-
-
-
- {{ formatMessage(messages.unlinkInstanceButton) }}
-
-
-
-
-
-
- {{ formatMessage(messages.reinstallModpackDescription) }}
-
-
-
-
-
-
- {{
- reinstalling
- ? formatMessage(messages.reinstallingModpackButton)
- : formatMessage(messages.reinstallModpackButton)
- }}
-
-
-
-
-
-
+
diff --git a/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue b/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue
index 6964a83f68..f27f874921 100644
--- a/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue
+++ b/apps/app-frontend/src/components/ui/modal/ConfirmModalWrapper.vue
@@ -1,13 +1,9 @@
+
-
+
-
+
diff --git a/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue b/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
index 8f1f3db3f2..01f8436628 100644
--- a/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
+++ b/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
@@ -1,12 +1,8 @@
+
diff --git a/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue b/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue
index 9358ee908c..3ac556f5a0 100644
--- a/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue
+++ b/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue
@@ -1,12 +1,8 @@
+
@@ -56,7 +46,5 @@ function onModalHide() {
:share-text="shareText"
:link="link"
:open-in-new-tab="openInNewTab"
- :on-hide="onModalHide"
- :noblur="!themeStore.advancedRendering"
/>
diff --git a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
index 7c1030d0af..e0a83a5d37 100644
--- a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
+++ b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
@@ -147,7 +147,7 @@ import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { update_managed_modrinth_version } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
-import { useInstall } from '@/store/install.js'
+import { injectServerInstall } from '@/providers/server-install'
type Dependency = Labrinth.Versions.v3.Dependency
type Version = Labrinth.Versions.v2.Version
@@ -188,7 +188,7 @@ type ProjectInfo = {
const { formatMessage } = useVIntl()
const formatDate = useFormatDateTime({ dateStyle: 'long' })
-const installStore = useInstall()
+const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise
const modal = ref>()
@@ -355,7 +355,7 @@ watch(
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.linked_data?.project_id
- if (serverProjectId) installStore.startInstallingServer(serverProjectId)
+ if (serverProjectId) startInstallingServer(serverProjectId)
try {
if (modpackVersionId.value && instance.value) {
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
@@ -364,7 +364,7 @@ async function handleUpdate() {
} catch (error) {
console.error('Error updating instance:', error)
} finally {
- if (serverProjectId) installStore.stopInstallingServer(serverProjectId)
+ if (serverProjectId) stopInstallingServer(serverProjectId)
}
}
diff --git a/apps/app-frontend/src/helpers/cache.js b/apps/app-frontend/src/helpers/cache.js
index bdf4bc6a47..ab10222526 100644
--- a/apps/app-frontend/src/helpers/cache.js
+++ b/apps/app-frontend/src/helpers/cache.js
@@ -67,3 +67,17 @@ export async function get_search_results_v3_many(ids, cacheBehaviour) {
export async function purge_cache_types(cacheTypes) {
return await invoke('plugin:cache|purge_cache_types', { cacheTypes })
}
+
+/**
+ * Get versions for a project (without changelogs for fast loading).
+ * Uses the cache system - versions are cached for 30 minutes.
+ * @param {string} projectId - The project ID
+ * @param {string} [cacheBehaviour] - Cache behaviour ('must_revalidate', etc.)
+ * @returns {Promise} Array of version objects (without changelogs) or null
+ */
+export async function get_project_versions(projectId, cacheBehaviour) {
+ return await invoke('plugin:cache|get_project_versions', {
+ projectId,
+ cacheBehaviour,
+ })
+}
diff --git a/apps/app-frontend/src/helpers/pack.js b/apps/app-frontend/src/helpers/pack.js
deleted file mode 100644
index 312dfc62aa..0000000000
--- a/apps/app-frontend/src/helpers/pack.js
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * All theseus API calls return serialized values (both return values and errors);
- * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
- * and deserialized into a usable JS object.
- */
-import { invoke } from '@tauri-apps/api/core'
-
-import { create } from './profile'
-
-// Installs pack from a version ID
-export async function create_profile_and_install(
- projectId,
- versionId,
- packTitle,
- iconUrl,
- createInstanceCallback = () => {},
-) {
- const location = {
- type: 'fromVersionId',
- project_id: projectId,
- version_id: versionId,
- title: packTitle,
- icon_url: iconUrl,
- }
- const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
- const profile = await create(
- profile_creator.name,
- profile_creator.gameVersion,
- profile_creator.modloader,
- profile_creator.loaderVersion,
- null,
- true,
- )
- createInstanceCallback(profile)
-
- return await invoke('plugin:pack|pack_install', { location, profile })
-}
-
-export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
- const location = {
- type: 'fromVersionId',
- project_id: projectId,
- version_id: versionId,
- title,
- }
- return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
-}
-
-// Installs pack from a path
-export async function create_profile_and_install_from_file(path) {
- const location = {
- type: 'fromFile',
- path: path,
- }
- const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
- const profile = await create(
- profile_creator.name,
- profile_creator.gameVersion,
- profile_creator.modloader,
- profile_creator.loaderVersion,
- null,
- true,
- )
- return await invoke('plugin:pack|pack_install', { location, profile })
-}
diff --git a/apps/app-frontend/src/helpers/pack.ts b/apps/app-frontend/src/helpers/pack.ts
new file mode 100644
index 0000000000..ae1131a3f8
--- /dev/null
+++ b/apps/app-frontend/src/helpers/pack.ts
@@ -0,0 +1,90 @@
+import { invoke } from '@tauri-apps/api/core'
+
+import { create } from './profile'
+import type { InstanceLoader } from './types'
+
+interface PackProfileCreator {
+ name: string
+ gameVersion: string
+ modloader: InstanceLoader
+ loaderVersion: string | null
+}
+
+interface PackLocationVersionId {
+ type: 'fromVersionId'
+ project_id: string
+ version_id: string
+ title: string
+ icon_url?: string
+}
+
+interface PackLocationFile {
+ type: 'fromFile'
+ path: string
+}
+
+export async function create_profile_and_install(
+ projectId: string,
+ versionId: string,
+ packTitle: string,
+ iconUrl?: string,
+ createInstanceCallback: (profile: string) => void = () => {},
+): Promise {
+ const location: PackLocationVersionId = {
+ type: 'fromVersionId',
+ project_id: projectId,
+ version_id: versionId,
+ title: packTitle,
+ icon_url: iconUrl,
+ }
+ const profile_creator = await invoke(
+ 'plugin:pack|pack_get_profile_from_pack',
+ { location },
+ )
+ const profile = await create(
+ profile_creator.name,
+ profile_creator.gameVersion,
+ profile_creator.modloader,
+ profile_creator.loaderVersion,
+ null,
+ true,
+ )
+ createInstanceCallback(profile)
+
+ return await invoke('plugin:pack|pack_install', { location, profile })
+}
+
+export async function install_to_existing_profile(
+ projectId: string,
+ versionId: string,
+ title: string,
+ profilePath: string,
+): Promise {
+ const location: PackLocationVersionId = {
+ type: 'fromVersionId',
+ project_id: projectId,
+ version_id: versionId,
+ title,
+ }
+ return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
+}
+
+export async function create_profile_and_install_from_file(path: string): Promise {
+ const location: PackLocationFile = {
+ type: 'fromFile',
+ path,
+ }
+ const profile_creator = await invoke(
+ 'plugin:pack|pack_get_profile_from_pack',
+ { location },
+ )
+ const profile = await create(
+ profile_creator.name,
+ profile_creator.gameVersion,
+ profile_creator.modloader,
+ profile_creator.loaderVersion,
+ null,
+ true,
+ )
+ return await invoke('plugin:pack|pack_install', { location, profile })
+}
diff --git a/apps/app-frontend/src/helpers/profile.js b/apps/app-frontend/src/helpers/profile.js
deleted file mode 100644
index ef920b7c4f..0000000000
--- a/apps/app-frontend/src/helpers/profile.js
+++ /dev/null
@@ -1,216 +0,0 @@
-/**
- * All theseus API calls return serialized values (both return values and errors);
- * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
- * and deserialized into a usable JS object.
- */
-import { invoke } from '@tauri-apps/api/core'
-
-import { install_to_existing_profile } from '@/helpers/pack.js'
-
-/// Add instance
-/*
- name: String, // the name of the profile, and relative path to create
- game_version: String, // the game version of the profile
- modloader: ModLoader, // the modloader to use
- - ModLoader is an enum, with the following variants: Vanilla, Forge, Fabric, Quilt
- loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
- icon: Path, // the icon for the profile
- - icon is a path to an image file, which will be copied into the profile directory
-*/
-
-export async function create(
- name,
- gameVersion,
- modloader,
- loaderVersion,
- icon,
- skipInstall,
- linkedData,
-) {
- //Trim string name to avoid "Unable to find directory"
- name = name.trim()
- return await invoke('plugin:profile-create|profile_create', {
- name,
- gameVersion,
- modloader,
- loaderVersion,
- icon,
- skipInstall,
- linkedData,
- })
-}
-
-// duplicate a profile
-export async function duplicate(path) {
- return await invoke('plugin:profile-create|profile_duplicate', { path })
-}
-
-// Remove a profile
-export async function remove(path) {
- return await invoke('plugin:profile|profile_remove', { path })
-}
-
-// Get a profile by path
-// Returns a Profile
-export async function get(path) {
- return await invoke('plugin:profile|profile_get', { path })
-}
-
-export async function get_many(paths) {
- return await invoke('plugin:profile|profile_get_many', { paths })
-}
-
-// Get a profile's projects
-// Returns a map of a path to profile file
-export async function get_projects(path, cacheBehaviour) {
- return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour })
-}
-
-// Get a profile's full fs path
-// Returns a path
-export async function get_full_path(path) {
- return await invoke('plugin:profile|profile_get_full_path', { path })
-}
-
-// Get's a mod's full fs path
-// Returns a path
-export async function get_mod_full_path(path, projectPath) {
- return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
-}
-
-// Get optimal java version from profile
-// Returns a java version
-export async function get_optimal_jre_key(path) {
- return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
-}
-
-// Get a copy of the profile set
-// Returns hashmap of path -> Profile
-export async function list() {
- return await invoke('plugin:profile|profile_list')
-}
-
-export async function check_installed(path, projectId) {
- return await invoke('plugin:profile|profile_check_installed', { path, projectId })
-}
-
-// Installs/Repairs a profile
-export async function install(path, force) {
- return await invoke('plugin:profile|profile_install', { path, force })
-}
-
-// Updates all of a profile's projects
-export async function update_all(path) {
- return await invoke('plugin:profile|profile_update_all', { path })
-}
-
-// Updates a specified project
-export async function update_project(path, projectPath) {
- return await invoke('plugin:profile|profile_update_project', { path, projectPath })
-}
-
-// Add a project to a profile from a version
-// Returns a path to the new project file
-export async function add_project_from_version(path, versionId) {
- return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId })
-}
-
-// Add a project to a profile from a path + project_type
-// Returns a path to the new project file
-export async function add_project_from_path(path, projectPath, projectType) {
- return await invoke('plugin:profile|profile_add_project_from_path', {
- path,
- projectPath,
- projectType,
- })
-}
-
-// Toggle disabling a project
-export async function toggle_disable_project(path, projectPath) {
- return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
-}
-
-// Remove a project
-export async function remove_project(path, projectPath) {
- return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
-}
-
-// Update a managed Modrinth profile to a specific version
-export async function update_managed_modrinth_version(path, versionId) {
- return await invoke('plugin:profile|profile_update_managed_modrinth_version', {
- path,
- versionId,
- })
-}
-
-// Repair a managed Modrinth profile
-export async function update_repair_modrinth(path) {
- return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
-}
-
-// Export a profile to .mrpack
-/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
-// Version id is optional (ie: 1.1.5)
-export async function export_profile_mrpack(
- path,
- exportLocation,
- includedOverrides,
- versionId,
- description,
- name,
-) {
- return await invoke('plugin:profile|profile_export_mrpack', {
- path,
- exportLocation,
- includedOverrides,
- versionId,
- description,
- name,
- })
-}
-
-// Given a folder path, populate an array of all the subfolders
-// Intended to be used for finding potential override folders
-// profile
-// -- mods
-// -- resourcepacks
-// -- file1
-// => [mods, resourcepacks]
-// allows selection for 'included_overrides' in export_profile_mrpack
-export async function get_pack_export_candidates(profilePath) {
- return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
-}
-
-// Run Minecraft using a pathed profile
-// Returns PID of child
-export async function run(path, serverAddress = null) {
- return await invoke('plugin:profile|profile_run', { path, serverAddress })
-}
-
-export async function kill(path) {
- return await invoke('plugin:profile|profile_kill', { path })
-}
-
-// Edits a profile
-export async function edit(path, editProfile) {
- return await invoke('plugin:profile|profile_edit', { path, editProfile })
-}
-
-// Edits a profile's icon
-export async function edit_icon(path, iconPath) {
- return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
-}
-
-export async function finish_install(instance) {
- if (instance.install_stage !== 'pack_installed') {
- let linkedData = instance.linked_data
- await install_to_existing_profile(
- linkedData.project_id,
- linkedData.version_id,
- instance.name,
- instance.path,
- )
- } else {
- await install(instance.path, false)
- }
-}
diff --git a/apps/app-frontend/src/helpers/profile.ts b/apps/app-frontend/src/helpers/profile.ts
new file mode 100644
index 0000000000..7995c70045
--- /dev/null
+++ b/apps/app-frontend/src/helpers/profile.ts
@@ -0,0 +1,298 @@
+/**
+ * All theseus API calls return serialized values (both return values and errors);
+ * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
+ * and deserialized into a usable JS object.
+ */
+import type { Labrinth } from '@modrinth/api-client'
+import type { ContentItem, ContentOwner } from '@modrinth/ui'
+import { invoke } from '@tauri-apps/api/core'
+
+import { install_to_existing_profile } from '@/helpers/pack'
+
+import type {
+ CacheBehaviour,
+ ContentFile,
+ ContentFileProjectType,
+ GameInstance,
+ InstanceLoader,
+} from './types'
+
+// Add instance
+/*
+ name: String, // the name of the profile, and relative path to create
+ game_version: String, // the game version of the profile
+ modloader: ModLoader, // the modloader to use
+ - ModLoader is an enum, with the following variants: Vanilla, Forge, Fabric, Quilt
+ loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
+ icon: Path, // the icon for the profile
+ - icon is a path to an image file, which will be copied into the profile directory
+*/
+
+export async function create(
+ name: string,
+ gameVersion: string,
+ modloader: InstanceLoader,
+ loaderVersion: string | null,
+ icon: string | null,
+ skipInstall: boolean,
+ linkedData?: { project_id: string; version_id: string; locked: boolean } | null,
+): Promise {
+ // Trim string name to avoid "Unable to find directory"
+ name = name.trim()
+ return await invoke('plugin:profile-create|profile_create', {
+ name,
+ gameVersion,
+ modloader,
+ loaderVersion,
+ icon,
+ skipInstall,
+ linkedData,
+ })
+}
+
+// duplicate a profile
+export async function duplicate(path: string): Promise {
+ return await invoke('plugin:profile-create|profile_duplicate', { path })
+}
+
+// Remove a profile
+export async function remove(path: string): Promise {
+ return await invoke('plugin:profile|profile_remove', { path })
+}
+
+// Get a profile by path
+// Returns a Profile
+export async function get(path: string): Promise {
+ return await invoke('plugin:profile|profile_get', { path })
+}
+
+export async function get_many(paths: string[]): Promise {
+ return await invoke('plugin:profile|profile_get_many', { paths })
+}
+
+// Get a profile's projects
+// Returns a map of a path to profile file
+export async function get_projects(
+ path: string,
+ cacheBehaviour?: CacheBehaviour,
+): Promise> {
+ return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour })
+}
+
+// Get just the installed project IDs for a profile (lightweight, skips update checks)
+export async function get_installed_project_ids(path: string): Promise {
+ return await invoke('plugin:profile|profile_get_installed_project_ids', { path })
+}
+
+// Get content items with rich metadata for a profile
+// Returns content items filtered to exclude modpack files (if linked),
+// sorted alphabetically by project name
+export async function get_content_items(
+ path: string,
+ cacheBehaviour?: CacheBehaviour,
+): Promise {
+ return await invoke('plugin:profile|profile_get_content_items', { path, cacheBehaviour })
+}
+
+// Linked modpack info returned from backend
+export interface LinkedModpackInfo {
+ project: Labrinth.Projects.v2.Project
+ version: Labrinth.Versions.v2.Version
+ owner: ContentOwner | null
+ has_update: boolean
+ update_version_id: string | null
+ update_version: Labrinth.Versions.v2.Version | null
+}
+
+// Get linked modpack info for a profile
+// Returns project, version, and owner information for the linked modpack,
+// or null if the profile is not linked to a modpack
+export async function get_linked_modpack_info(
+ path: string,
+ cacheBehaviour?: CacheBehaviour,
+): Promise {
+ return await invoke('plugin:profile|profile_get_linked_modpack_info', { path, cacheBehaviour })
+}
+
+// Get content items that are part of the linked modpack
+// Returns the modpack's dependencies as ContentItem list
+// Returns empty array if the profile is not linked to a modpack
+export async function get_linked_modpack_content(
+ path: string,
+ cacheBehaviour?: CacheBehaviour,
+): Promise {
+ return await invoke('plugin:profile|profile_get_linked_modpack_content', { path, cacheBehaviour })
+}
+
+// Convert a list of dependencies into ContentItems with rich metadata
+export async function get_dependencies_as_content_items(
+ dependencies: Labrinth.Versions.v3.Dependency[],
+ cacheBehaviour?: CacheBehaviour,
+): Promise {
+ return await invoke('plugin:profile|profile_get_dependencies_as_content_items', {
+ dependencies,
+ cacheBehaviour,
+ })
+}
+
+// Get a profile's full fs path
+// Returns a path
+export async function get_full_path(path: string): Promise {
+ return await invoke('plugin:profile|profile_get_full_path', { path })
+}
+
+// Get's a mod's full fs path
+// Returns a path
+export async function get_mod_full_path(path: string, projectPath: string): Promise {
+ return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
+}
+
+// Get optimal java version from profile
+// Returns a java version
+export async function get_optimal_jre_key(path: string): Promise {
+ return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
+}
+
+// Get a copy of the profile set
+// Returns hashmap of path -> Profile
+export async function list(): Promise {
+ return await invoke('plugin:profile|profile_list')
+}
+
+export async function check_installed(path: string, projectId: string): Promise {
+ return await invoke('plugin:profile|profile_check_installed', { path, projectId })
+}
+
+export async function check_installed_batch(projectId: string): Promise> {
+ return await invoke('plugin:profile|profile_check_installed_batch', { projectId })
+}
+
+// Installs/Repairs a profile
+export async function install(path: string, force: boolean): Promise {
+ return await invoke('plugin:profile|profile_install', { path, force })
+}
+
+// Updates all of a profile's projects
+export async function update_all(path: string): Promise> {
+ return await invoke('plugin:profile|profile_update_all', { path })
+}
+
+// Updates a specified project
+export async function update_project(path: string, projectPath: string): Promise {
+ return await invoke('plugin:profile|profile_update_project', { path, projectPath })
+}
+
+// Add a project to a profile from a version
+// Returns a path to the new project file
+export async function add_project_from_version(path: string, versionId: string): Promise {
+ return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId })
+}
+
+// Add a project to a profile from a path + project_type
+// Returns a path to the new project file
+export async function add_project_from_path(
+ path: string,
+ projectPath: string,
+ projectType?: ContentFileProjectType,
+): Promise {
+ return await invoke('plugin:profile|profile_add_project_from_path', {
+ path,
+ projectPath,
+ projectType,
+ })
+}
+
+// Toggle disabling a project
+export async function toggle_disable_project(path: string, projectPath: string): Promise {
+ return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
+}
+
+// Remove a project
+export async function remove_project(path: string, projectPath: string): Promise {
+ return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
+}
+
+// Update a managed Modrinth profile to a specific version
+export async function update_managed_modrinth_version(
+ path: string,
+ versionId: string,
+): Promise {
+ return await invoke('plugin:profile|profile_update_managed_modrinth_version', {
+ path,
+ versionId,
+ })
+}
+
+// Repair a managed Modrinth profile
+export async function update_repair_modrinth(path: string): Promise {
+ return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
+}
+
+// Export a profile to .mrpack
+// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
+// Version id is optional (ie: 1.1.5)
+export async function export_profile_mrpack(
+ path: string,
+ exportLocation: string,
+ includedOverrides: string[],
+ versionId?: string,
+ description?: string,
+ name?: string,
+): Promise {
+ return await invoke('plugin:profile|profile_export_mrpack', {
+ path,
+ exportLocation,
+ includedOverrides,
+ versionId,
+ description,
+ name,
+ })
+}
+
+// Given a folder path, populate an array of all the subfolders
+// Intended to be used for finding potential override folders
+// profile
+// -- mods
+// -- resourcepacks
+// -- file1
+// => [mods, resourcepacks]
+// allows selection for 'included_overrides' in export_profile_mrpack
+export async function get_pack_export_candidates(profilePath: string): Promise {
+ return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
+}
+
+// Run Minecraft using a pathed profile
+// Returns PID of child
+export async function run(path: string, serverAddress: string | null = null): Promise {
+ return await invoke('plugin:profile|profile_run', { path, serverAddress })
+}
+
+export async function kill(path: string): Promise {
+ return await invoke('plugin:profile|profile_kill', { path })
+}
+
+// Edits a profile
+export async function edit(path: string, editProfile: Partial): Promise {
+ return await invoke('plugin:profile|profile_edit', { path, editProfile })
+}
+
+// Edits a profile's icon
+export async function edit_icon(path: string, iconPath: string | null): Promise {
+ return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
+}
+
+export async function finish_install(instance: GameInstance): Promise {
+ if (instance.install_stage !== 'pack_installed') {
+ const linkedData = instance.linked_data
+ if (linkedData) {
+ await install_to_existing_profile(
+ linkedData.project_id,
+ linkedData.version_id,
+ instance.name,
+ instance.path,
+ )
+ }
+ } else {
+ await install(instance.path, false)
+ }
+}
diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts
index 4f5487a0d6..de90ff404d 100644
--- a/apps/app-frontend/src/helpers/types.d.ts
+++ b/apps/app-frontend/src/helpers/types.d.ts
@@ -49,17 +49,10 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
- hash: string
- file_name: string
- size: number
- metadata?: FileMetadata
- update_version_id?: string
- project_type: ContentFileProjectType
-}
-
-type FileMetadata = {
- project_id: string
- version_id: string
+ metadata?: {
+ project_id: string
+ version_id: string
+ }
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 5a28850b1c..851b547c1c 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -5,6 +5,66 @@
"app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers"
},
+ "app.export-modal.description-placeholder": {
+ "message": "Enter modpack description..."
+ },
+ "app.export-modal.export-button": {
+ "message": "Export"
+ },
+ "app.export-modal.header": {
+ "message": "Export modpack"
+ },
+ "app.export-modal.modpack-name-label": {
+ "message": "Modpack Name"
+ },
+ "app.export-modal.modpack-name-placeholder": {
+ "message": "Modpack name"
+ },
+ "app.export-modal.select-files-label": {
+ "message": "Select files and folders to include in pack"
+ },
+ "app.export-modal.version-number-label": {
+ "message": "Version number"
+ },
+ "app.export-modal.version-number-placeholder": {
+ "message": "1.0.0"
+ },
+ "app.instance.mods.content-type-project": {
+ "message": "project"
+ },
+ "app.instance.mods.copy-link": {
+ "message": "Copy link"
+ },
+ "app.instance.mods.installing": {
+ "message": "Installing..."
+ },
+ "app.instance.mods.modpack-fallback": {
+ "message": "Modpack"
+ },
+ "app.instance.mods.project-was-added": {
+ "message": "\"{name}\" was added"
+ },
+ "app.instance.mods.projects-were-added": {
+ "message": "{count} projects were added"
+ },
+ "app.instance.mods.share-text": {
+ "message": "Check out the projects I'm using in my modpack!"
+ },
+ "app.instance.mods.share-title": {
+ "message": "Sharing modpack content"
+ },
+ "app.instance.mods.show-file": {
+ "message": "Show file"
+ },
+ "app.instance.mods.successfully-uploaded": {
+ "message": "Successfully uploaded"
+ },
+ "app.instance.mods.unknown-version": {
+ "message": "Unknown"
+ },
+ "app.instance.mods.updating": {
+ "message": "Updating..."
+ },
"app.modal.install-to-play.content-required": {
"message": "Content required"
},
@@ -227,12 +287,6 @@
"instance.edit-world.title": {
"message": "Edit world"
},
- "instance.filter.disabled": {
- "message": "Disabled projects"
- },
- "instance.filter.updates-available": {
- "message": "Updates available"
- },
"instance.server-modal.address": {
"message": "Address"
},
@@ -341,150 +395,9 @@
"instance.settings.tabs.installation": {
"message": "Installation"
},
- "instance.settings.tabs.installation.change-version.already-installed.modded": {
- "message": "{platform} {version} for Minecraft {game_version} already installed"
- },
- "instance.settings.tabs.installation.change-version.already-installed.vanilla": {
- "message": "Vanilla {game_version} already installed"
- },
- "instance.settings.tabs.installation.change-version.button": {
- "message": "Change version"
- },
- "instance.settings.tabs.installation.change-version.button.install": {
- "message": "Install"
- },
- "instance.settings.tabs.installation.change-version.button.installing": {
- "message": "Installing"
- },
- "instance.settings.tabs.installation.change-version.cannot-while-fetching": {
- "message": "Fetching modpack versions"
- },
- "instance.settings.tabs.installation.change-version.in-progress": {
- "message": "Installing new version"
- },
- "instance.settings.tabs.installation.currently-installed": {
- "message": "Currently installed"
- },
- "instance.settings.tabs.installation.debug-information": {
- "message": "Debug information:"
- },
- "instance.settings.tabs.installation.fetching-modpack-details": {
- "message": "Fetching modpack details"
- },
- "instance.settings.tabs.installation.game-version": {
- "message": "Game version"
- },
- "instance.settings.tabs.installation.install": {
- "message": "Install"
- },
- "instance.settings.tabs.installation.install.in-progress": {
- "message": "Installation in progress"
- },
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} version"
},
- "instance.settings.tabs.installation.minecraft-version": {
- "message": "Minecraft {version}"
- },
- "instance.settings.tabs.installation.no-connection": {
- "message": "Cannot fetch linked modpack details. Please check your internet connection."
- },
- "instance.settings.tabs.installation.no-loader-versions": {
- "message": "{loader} is not available for Minecraft {version}. Try another mod loader."
- },
- "instance.settings.tabs.installation.no-modpack-found": {
- "message": "This instance is linked to a modpack, but the modpack could not be found on Modrinth."
- },
- "instance.settings.tabs.installation.platform": {
- "message": "Platform"
- },
- "instance.settings.tabs.installation.reinstall.button": {
- "message": "Reinstall modpack"
- },
- "instance.settings.tabs.installation.reinstall.button.reinstalling": {
- "message": "Reinstalling modpack"
- },
- "instance.settings.tabs.installation.reinstall.confirm.description": {
- "message": "Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation. This may fix unexpected behavior if changes have been made to the instance, but if your worlds now depend on additional installed content, it may break existing worlds."
- },
- "instance.settings.tabs.installation.reinstall.confirm.title": {
- "message": "Are you sure you want to reinstall this instance?"
- },
- "instance.settings.tabs.installation.reinstall.description": {
- "message": "Resets the instance's content to its original state, removing any mods or content you have added on top of the original modpack."
- },
- "instance.settings.tabs.installation.reinstall.title": {
- "message": "Reinstall modpack"
- },
- "instance.settings.tabs.installation.repair.button": {
- "message": "Repair"
- },
- "instance.settings.tabs.installation.repair.button.repairing": {
- "message": "Repairing"
- },
- "instance.settings.tabs.installation.repair.confirm.description": {
- "message": "Repairing reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors, but will not resolve issues or crashes related to installed mods."
- },
- "instance.settings.tabs.installation.repair.confirm.title": {
- "message": "Repair instance?"
- },
- "instance.settings.tabs.installation.repair.in-progress": {
- "message": "Repair in progress"
- },
- "instance.settings.tabs.installation.reset-selections": {
- "message": "Reset to current"
- },
- "instance.settings.tabs.installation.show-all-versions": {
- "message": "Show all versions"
- },
- "instance.settings.tabs.installation.tooltip.action.change-version": {
- "message": "change version"
- },
- "instance.settings.tabs.installation.tooltip.action.install": {
- "message": "install"
- },
- "instance.settings.tabs.installation.tooltip.action.reinstall": {
- "message": "reinstall"
- },
- "instance.settings.tabs.installation.tooltip.action.repair": {
- "message": "repair"
- },
- "instance.settings.tabs.installation.tooltip.cannot-while-installing": {
- "message": "Cannot {action} while installing"
- },
- "instance.settings.tabs.installation.tooltip.cannot-while-offline": {
- "message": "Cannot {action} while offline"
- },
- "instance.settings.tabs.installation.tooltip.cannot-while-repairing": {
- "message": "Cannot {action} while repairing"
- },
- "instance.settings.tabs.installation.unknown-version": {
- "message": "(unknown version)"
- },
- "instance.settings.tabs.installation.unlink-server-vanilla.description": {
- "message": "This instance is linked to a server, which means you can't change the Minecraft version. Unlinking will permanently disconnect this instance from the server."
- },
- "instance.settings.tabs.installation.unlink-server.description": {
- "message": "This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server."
- },
- "instance.settings.tabs.installation.unlink-server.title": {
- "message": "Unlink from server"
- },
- "instance.settings.tabs.installation.unlink.button": {
- "message": "Unlink instance"
- },
- "instance.settings.tabs.installation.unlink.confirm.description": {
- "message": "If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal instance."
- },
- "instance.settings.tabs.installation.unlink.confirm.title": {
- "message": "Are you sure you want to unlink this instance?"
- },
- "instance.settings.tabs.installation.unlink.description": {
- "message": "This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack."
- },
- "instance.settings.tabs.installation.unlink.title": {
- "message": "Unlink from modpack"
- },
"instance.settings.tabs.java": {
"message": "Java and memory"
},
diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue
index 0578390317..6ff99d07d4 100644
--- a/apps/app-frontend/src/pages/Browse.vue
+++ b/apps/app-frontend/src/pages/Browse.vue
@@ -44,19 +44,21 @@ import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import {
get as getInstance,
- get_projects as getInstanceProjects,
+ get_installed_project_ids as getInstalledProjectIds,
kill,
list as listInstances,
} from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import { getServerLatency } from '@/helpers/worlds'
+import { injectServerInstall } from '@/providers/server-install'
import { useBreadcrumbs } from '@/store/breadcrumbs'
-import { getServerAddress, playServerProject, useInstall } from '@/store/install.js'
+import { getServerAddress } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
-const installStore = useInstall()
+const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
+ injectServerInstall()
const router = useRouter()
const route = useRoute()
@@ -97,38 +99,33 @@ type Instance = {
}
}
-type InstanceProject = {
- metadata: {
- project_id: string
- }
-}
-
const instance: Ref = ref(null)
-const instanceProjects: Ref = ref(null)
+const installedProjectIds: Ref = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref([])
const isServerInstance = ref(false)
+// Non-reactive snapshot used by instanceFilters to avoid triggering a search
+// refresh when an item is installed mid-browse (which causes content shift).
+// Synced before each search triggered by filter/page/query changes.
+let newlyInstalledSnapshot: string[] = []
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
-await updateInstanceContext()
-
-watch(
- () => [route.query.i, route.query.ai, route.path],
- () => {
- updateInstanceContext()
- },
-)
+await initInstanceContext()
-async function updateInstanceContext() {
+async function initInstanceContext() {
if (route.query.i) {
- ;[instance.value, instanceProjects.value] = await Promise.all([
- getInstance(route.query.i).catch(handleError),
- getInstanceProjects(route.query.i).catch(handleError),
- ])
- newlyInstalled.value = []
+ instance.value = await getInstance(route.query.i).catch(handleError)
+
+ // Load installed project IDs in background — the page and initial search render immediately.
+ // When this resolves, instanceFilters recomputes and triggers a search refresh
+ // that applies the "hide installed" negative filters and marks installed badges.
+ getInstalledProjectIds(route.query.i)
+ .then((ids) => {
+ installedProjectIds.value = ids
+ })
+ .catch(handleError)
- isServerInstance.value = false
if (instance.value?.linked_data?.project_id) {
const projectV3 = await get_project_v3(
instance.value.linked_data.project_id,
@@ -143,11 +140,6 @@ async function updateInstanceContext() {
if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) {
instanceHideInstalled.value = route.query.ai === 'true'
}
-
- if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
- instance.value = null
- instanceHideInstalled.value = false
- }
}
const instanceFilters = computed(() => {
@@ -180,15 +172,11 @@ const instanceFilters = computed(() => {
})
}
- if (instanceHideInstalled.value && instanceProjects.value) {
- const installedMods = Object.values(instanceProjects.value)
- .filter((x) => x.metadata)
- .map((x) => x.metadata.project_id)
-
- installedMods.push(...newlyInstalled.value)
+ if (instanceHideInstalled.value && installedProjectIds.value) {
+ const allInstalled = [...installedProjectIds.value, ...newlyInstalledSnapshot]
- installedMods
- ?.map((x) => ({
+ allInstalled
+ .map((x) => ({
type: 'project_id',
option: `project_id:${x}`,
negative: true,
@@ -221,6 +209,19 @@ const {
createPageParams,
} = useSearch(projectTypes, tags, instanceFilters)
+const activeLoader = computed(() => {
+ const filter = currentFilters.value.find((f) => f.type === 'mod_loader')
+ if (filter) return filter.option
+ if (projectType.value === 'datapack' || projectType.value === 'resourcepack') return 'vanilla'
+ return instance.value?.loader ?? null
+})
+
+const activeGameVersion = computed(() => {
+ const filter = currentFilters.value.find((f) => f.type === 'game_version')
+ if (filter) return filter.option
+ return instance.value?.game_version ?? null
+})
+
const serverHits = shallowRef([])
const serverPings = shallowRef>({})
const runningServerProjects = ref>({})
@@ -256,7 +257,7 @@ async function handlePlayServerProject(projectId: string) {
function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
const address = getServerAddress(project.minecraft_java_server)
if (!address) return
- installStore.showAddServerToInstanceModal(project.name, address)
+ showAddServerToInstanceModal(project.name, address)
}
const unlistenProcesses = await process_listener(
@@ -303,7 +304,7 @@ async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
}
const previousFilterState = ref('')
-const isRefreshing = ref(false)
+let searchVersion = 0
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -337,15 +338,19 @@ const effectiveRequestParams = computed(() => {
return projectType.value === 'server' ? serverRequestParams.value : requestParams.value
})
-watch(effectiveRequestParams, async () => {
+let searchDebounceTimer: ReturnType | null = null
+
+watch(effectiveRequestParams, () => {
if (!route.params.projectType) return
- await nextTick()
- refreshSearch()
+ if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
+ searchDebounceTimer = setTimeout(() => {
+ refreshSearch()
+ }, 200)
})
async function refreshSearch() {
- if (isRefreshing.value) return
- isRefreshing.value = true
+ const version = ++searchVersion
+ newlyInstalledSnapshot = [...newlyInstalled.value]
try {
const isServer = projectType.value === 'server'
@@ -355,6 +360,8 @@ async function refreshSearch() {
result: Labrinth.Search.v3.SearchResults
} | null
+ if (version !== searchVersion) return
+
const searchResults = rawResults?.result ?? { hits: [], total_hits: 0 }
const hits = searchResults.hits ?? []
serverHits.value = hits
@@ -372,6 +379,8 @@ async function refreshSearch() {
result: SearchResults
} | null
+ if (version !== searchVersion) return
+
if (!rawResults) {
rawResults = {
result: {
@@ -383,16 +392,14 @@ async function refreshSearch() {
}
}
if (instance.value) {
- const installedProjectIds = new Set([
+ const allInstalledIds = new Set([
...newlyInstalled.value,
- ...Object.values(instanceProjects.value ?? {})
- .filter((x) => x.metadata)
- .map((x) => x.metadata.project_id),
+ ...(installedProjectIds.value ?? []),
])
rawResults.result.hits = rawResults.result.hits.map((val) => ({
...val,
- installed: installedProjectIds.has(val.project_id),
+ installed: allInstalledIds.has(val.project_id),
}))
}
results.value = rawResults.result
@@ -446,11 +453,13 @@ async function refreshSearch() {
.join('&')
const newUrl = `${route.path}${queryString ? '?' + queryString : ''}`
window.history.replaceState(window.history.state, '', newUrl)
+
+ loading.value = false
} catch (err) {
console.error('Error refreshing search:', err)
- } finally {
- loading.value = false
- isRefreshing.value = false
+ if (version === searchVersion) {
+ loading.value = false
+ }
}
}
@@ -495,7 +504,8 @@ const selectableProjectTypes = computed(() => {
if (
availableGameVersions.value &&
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
- availableGameVersions.value.findIndex((x) => x.version === '1.13')
+ availableGameVersions.value.findIndex((x) => x.version === '1.13') &&
+ !isServerInstance.value
) {
dataPacks = true
}
@@ -854,18 +864,12 @@ previousFilterState.value = JSON.stringify({
handlePlayServerProject(project.project_id)"
>
{{
- (installStore.installingServerProjects as string[]).includes(
- project.project_id,
- )
+ (installingServerProjects as string[]).includes(project.project_id)
? 'Installing...'
: 'Play'
}}
@@ -882,6 +886,19 @@ previousFilterState.value = JSON.stringify({
:project-type="projectType"
:project="result"
:instance="instance ?? undefined"
+ :active-loader="activeLoader ?? undefined"
+ :active-game-version="activeGameVersion ?? undefined"
+ :categories="[
+ ...(categories ?? []).filter(
+ (cat) =>
+ result?.display_categories.includes(cat.name) && cat.project_type === projectType,
+ ),
+ ...(loaders ?? []).filter(
+ (loader) =>
+ result?.display_categories.includes(loader.name) &&
+ loader.supported_project_types?.includes(projectType),
+ ),
+ ]"
:installed="result.installed || newlyInstalled.includes(result.project_id || '')"
@install="
(id) => {
diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue
index 2b40f07089..07e868b34d 100644
--- a/apps/app-frontend/src/pages/instance/Index.vue
+++ b/apps/app-frontend/src/pages/instance/Index.vue
@@ -110,9 +110,13 @@
stopInstance('InstanceSubpage')"
>
@@ -334,14 +339,15 @@ import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils.js'
import { get_server_status } from '@/helpers/worlds'
+import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
-import { playServerProject } from '@/store/install.js'
import { useBreadcrumbs, useLoading } from '@/store/state'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
+const { playServerProject } = injectServerInstall()
const route = useRoute()
const router = useRouter()
@@ -607,6 +613,10 @@ const unlistenProfiles = await profile_listener(
return
}
instance.value = await get(route.params.id as string).catch(handleError)
+ if (!instance.value?.linked_data?.project_id) {
+ linkedProjectV3.value = undefined
+ isServerInstance.value = false
+ }
}
},
)
diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue
index 7c43288d79..83436bd00e 100644
--- a/apps/app-frontend/src/pages/instance/Mods.vue
+++ b/apps/app-frontend/src/pages/instance/Mods.vue
@@ -1,1181 +1,757 @@
-
-
-
-
-
-
-
- {{ filter.formattedName }}
-
-
-
(currentPage = page)"
- />
-
-
-
+
+
+
+
-
-
-
- Update
-
-
-
- Share
- Project names
- File names
- Project links
- Markdown links
-
-
-
- Enable
-
-
- Disable
-
-
- Remove
-
-
-
-
-
-
-
- Refresh
-
-
-
- Update all
-
-
-
- Update pack
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Show file
- Copy link
-
-
-
-
-
-
(currentPage = page)"
- />
-
+ :is-app="true"
+ :is-modpack="updatingModpack"
+ :project-icon-url="
+ updatingModpack ? linkedModpackProject?.icon_url : updatingProject?.project?.icon_url
+ "
+ :project-name="
+ updatingModpack
+ ? (linkedModpackProject?.title ?? formatMessage(messages.modpackFallback))
+ : (updatingProject?.project?.title ?? updatingProject?.file_name)
+ "
+ :loading="loadingVersions"
+ :loading-changelog="loadingChangelog"
+ @update="handleModalUpdate"
+ @cancel="resetUpdateState"
+ @version-select="handleVersionSelect"
+ @version-hover="handleVersionHover"
+ />
-
-
-
-
-
You haven't added any content to this instance yet.
-
-
-
-
-
-
-
-
+
+
+ await nextTick()
-
+// Save modal state when navigating away so it can be restored on back
+const removeBeforeEach = router.beforeEach(() => {
+ const state = modpackContentModal.value?.getState()
+ savedModalState = state ?? null
+})
-
+watch(
+ () => props.instance?.linked_data,
+ async (newLinkedData, oldLinkedData) => {
+ if (oldLinkedData && !newLinkedData) {
+ await initProjects('must_revalidate')
+ }
+ },
+)
+
+onUnmounted(() => {
+ removeBeforeEach()
+ unlisten()
+ unlistenProfiles()
+})
+
diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue
index 67f9035a8b..08e8d7eca0 100644
--- a/apps/app-frontend/src/pages/instance/Worlds.vue
+++ b/apps/app-frontend/src/pages/instance/Worlds.vue
@@ -176,13 +176,11 @@ import {
start_join_singleplayer_world,
type World,
} from '@/helpers/worlds.ts'
-import {
- ensureManagedServerWorldExists,
- getServerAddress,
- playServerProject,
-} from '@/store/install'
+import { injectServerInstall } from '@/providers/server-install'
+import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install'
const { handleError } = injectNotificationManager()
+const { playServerProject } = injectServerInstall()
const route = useRoute()
const addServerModal = ref>()
diff --git a/apps/app-frontend/src/pages/library/Index.vue b/apps/app-frontend/src/pages/library/Index.vue
index b6ab192d80..9694143aad 100644
--- a/apps/app-frontend/src/pages/library/Index.vue
+++ b/apps/app-frontend/src/pages/library/Index.vue
@@ -1,17 +1,17 @@
diff --git a/apps/frontend/src/components/ui/create-project-version/CreateProjectVersionModal.vue b/apps/frontend/src/components/ui/create-project-version/CreateProjectVersionModal.vue
index c918fe974a..9ad8d20819 100644
--- a/apps/frontend/src/components/ui/create-project-version/CreateProjectVersionModal.vue
+++ b/apps/frontend/src/components/ui/create-project-version/CreateProjectVersionModal.vue
@@ -4,6 +4,7 @@
:stages="ctx.stageConfigs"
:context="ctx"
:breadcrumbs="!editingVersion"
+ :close-on-click-outside="false"
@hide="() => (modalOpen = false)"
/>
-
-
-
-
-
-
-
-
-
- {{ type }} version
-
-
-
-
-
-
- Show any beta and alpha releases
-
-
-
-
-
-
-
-
{{ type }} version
-
-
- {{ formattedVersions.game_versions[0] }}
-
-
- {{ formattedVersions.loaders[0] }}
-
-
-
-
-
-
- Show any beta and alpha releases
-
-
-
-
-
-
- {{
- noCompatibleVersions
- ? `No compatible versions of this ${type.toLowerCase()} were found`
- : versionFilter
- ? 'Game version and platform is provided by the server'
- : 'Incompatible game version and platform versions are unlocked'
- }}
-
-
-
- {{
- noCompatibleVersions
- ? `No versions compatible with your server were found. You can still select any available version.`
- : versionFilter
- ? `Unlocking this filter may allow you to change this ${type.toLowerCase()}
- to an incompatible version.`
- : "You might see versions listed that aren't compatible with your server configuration."
- }}
-
-
-
-
-
-
-
- {{
- filtersRef?.selectedPlatforms.length === 0
- ? 'All platforms'
- : filtersRef?.selectedPlatforms
- .map((x) => {
- return formatLoader(formatMessage, x)
- })
- .join(', ')
- }}
-
-
-
-
-
- {{
- filtersRef?.selectedGameVersions.length === 0
- ? 'All game versions'
- : filtersRef?.selectedGameVersions.join(', ')
- }}
-
-
-
-
-
- {
- versionFilter = !versionFilter
- setInitialFilters()
- updateFiltersToUi()
- }
- "
- >
-
- {{
- gameVersions.length < 2 && platforms.length < 2
- ? 'No other platforms or versions available'
- : versionFilter
- ? 'Unlock'
- : 'Return to compatibility'
- }}
-
-
-
-
-
-
-
- Something went wrong trying to load versions for this
- {{ type.toLocaleLowerCase() }}. Please try again later or contact support if the issue
- persists.
-
-
-
-
-
-
- Your server was created using a modpack. It's recommended to use the modpack's version of
- the mod.
-
- Modify modpack version
-
-
-
-
-
-
-
- Install
-
-
-
-
-
- Cancel
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FileItem.vue b/apps/frontend/src/components/ui/servers/FileItem.vue
deleted file mode 100644
index 6d74915743..0000000000
--- a/apps/frontend/src/components/ui/servers/FileItem.vue
+++ /dev/null
@@ -1,345 +0,0 @@
-
- e.key === 'Enter' && selectItem()"
- @mouseenter="handleMouseEnter"
- @dragstart="handleDragStart"
- @dragend="handleDragEnd"
- @dragenter.prevent="handleDragEnter"
- @dragover.prevent="handleDragOver"
- @dragleave.prevent="handleDragLeave"
- @drop.prevent="handleDrop"
- >
-
-
-
-
-
-
-
- {{ name }}
-
-
-
-
-
- {{ formattedSize }}
-
-
- {{ formattedCreationDate }}
-
-
- {{ formattedModifiedDate }}
-
-
-
-
- Extract
- Rename
- Move
- Download
- Delete
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FileManagerError.vue b/apps/frontend/src/components/ui/servers/FileManagerError.vue
deleted file mode 100644
index 84adf75b66..0000000000
--- a/apps/frontend/src/components/ui/servers/FileManagerError.vue
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
-
{{ title }}
-
- {{ message }}
-
-
-
-
-
- Try again
-
-
-
-
-
- Go to home folder
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FileVirtualList.vue b/apps/frontend/src/components/ui/servers/FileVirtualList.vue
deleted file mode 100644
index 800783aa0e..0000000000
--- a/apps/frontend/src/components/ui/servers/FileVirtualList.vue
+++ /dev/null
@@ -1,128 +0,0 @@
-
-
-
-
- $emit('contextmenu', item, x, y)"
- @toggle-select="$emit('toggle-select', item.path)"
- />
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue b/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
deleted file mode 100644
index 6c70ee9ae9..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
+++ /dev/null
@@ -1,173 +0,0 @@
-
-
-
-
-
-
-
-
- Home
-
-
-
-
-
-
-
-
-
-
- {{ segment || '' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- New file
- New folder
- Upload file
-
- Upload from .zip file
-
-
- Upload from .zip URL
-
-
- Install CurseForge pack
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesContextMenu.vue b/apps/frontend/src/components/ui/servers/FilesContextMenu.vue
deleted file mode 100644
index 2a3a5aaad1..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesContextMenu.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesCreateItemModal.vue b/apps/frontend/src/components/ui/servers/FilesCreateItemModal.vue
deleted file mode 100644
index 4ac3e3044e..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesCreateItemModal.vue
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesDeleteItemModal.vue b/apps/frontend/src/components/ui/servers/FilesDeleteItemModal.vue
deleted file mode 100644
index d5cd6f1382..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesDeleteItemModal.vue
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue b/apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue
deleted file mode 100644
index cb5cf5111d..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue
+++ /dev/null
@@ -1,136 +0,0 @@
-
-
-
-
-
-
-
-
- Home
-
-
-
-
-
-
-
-
- {{ segment || '' }}
-
-
-
-
-
- {{
- fileName
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Save as...
-
-
-
-
- Save & restart
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesEditor.vue b/apps/frontend/src/components/ui/servers/FilesEditor.vue
deleted file mode 100644
index f9961a130a..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesEditor.vue
+++ /dev/null
@@ -1,260 +0,0 @@
-
-
-
-
-
saveFileContent(true)"
- @save-as="saveFileContentAs"
- @save-restart="saveFileContentRestart"
- @share="requestShareLink"
- @navigate="(index) => emit('navigate', index)"
- />
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesImageViewer.vue b/apps/frontend/src/components/ui/servers/FilesImageViewer.vue
deleted file mode 100644
index e05024c8a5..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesImageViewer.vue
+++ /dev/null
@@ -1,180 +0,0 @@
-
-
-
-
-
-
-
{{ state.errorMessage || 'Invalid or empty image file.' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ Math.round(state.scale * 100) }}%
- Reset
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesLabelBar.vue b/apps/frontend/src/components/ui/servers/FilesLabelBar.vue
deleted file mode 100644
index 72bf21a295..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesLabelBar.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-
-
-
-
-
- Name
-
-
-
-
-
-
- Size
-
-
-
-
- Created
-
-
-
-
- Modified
-
-
-
- Actions
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesMoveItemModal.vue b/apps/frontend/src/components/ui/servers/FilesMoveItemModal.vue
deleted file mode 100644
index 3798073117..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesMoveItemModal.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesRenameItemModal.vue b/apps/frontend/src/components/ui/servers/FilesRenameItemModal.vue
deleted file mode 100644
index bcb3fdeece..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesRenameItemModal.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesUploadConflictModal.vue b/apps/frontend/src/components/ui/servers/FilesUploadConflictModal.vue
deleted file mode 100644
index b655d29d5e..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesUploadConflictModal.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-
-
-
-
-
- Over 100 files will be overwritten if you proceed with extraction; here is just some of
- them:
-
-
- The following {{ files.length }} files already exist on your server, and will be
- overwritten if you proceed with extraction:
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue b/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue
deleted file mode 100644
index 773e015ad1..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
-
-
- Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue b/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
deleted file mode 100644
index 344e3d06c5..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
+++ /dev/null
@@ -1,335 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- {{ props.fileType ? props.fileType : 'File' }} uploads
-
- {{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ item.file.name }}
-
{{ item.size }}
-
-
-
- Done
-
-
- Failed - File already exists
-
-
- Failed - {{ item.error?.message || 'An unexpected error occured.' }}
-
-
- Failed - Incorrect file type
-
-
-
- {{ item.progress }}%
-
-
- Cancel
-
-
-
- Cancelled
-
-
- {{ item.progress }}%
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/FilesUploadZipUrlModal.vue b/apps/frontend/src/components/ui/servers/FilesUploadZipUrlModal.vue
deleted file mode 100644
index 98a7ae24ec..0000000000
--- a/apps/frontend/src/components/ui/servers/FilesUploadZipUrlModal.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/InstallingTicker.vue b/apps/frontend/src/components/ui/servers/InstallingTicker.vue
deleted file mode 100644
index 5d43a284cb..0000000000
--- a/apps/frontend/src/components/ui/servers/InstallingTicker.vue
+++ /dev/null
@@ -1,76 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue b/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue
index 2c9072a731..e7b0c74ed7 100644
--- a/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue
+++ b/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue
@@ -64,15 +64,22 @@
diff --git a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue
index c465180bb2..6e4c7c3db8 100644
--- a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue
+++ b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue
@@ -152,10 +152,7 @@
This does not affect your backups, which are stored off-site.
-
+
@@ -205,6 +202,8 @@ import {
BackupWarning,
ButtonStyled,
Combobox,
+ injectModrinthClient,
+ injectModrinthServerContext,
injectNotificationManager,
NewModal,
Toggle,
@@ -213,12 +212,13 @@ import {
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch } from 'ofetch'
-import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import LoaderIcon from './icons/LoaderIcon.vue'
import LoadingIcon from './icons/LoadingIcon.vue'
+const { server, serverId } = injectModrinthServerContext()
+const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -236,7 +236,6 @@ type VersionMap = Record
type VersionCache = Record
const props = defineProps<{
- server: ModrinthServer
currentLoader: Loaders | undefined
backupInProgress?: BackupInProgressReason
initialSetup?: boolean
@@ -472,11 +471,14 @@ const handleReinstall = async () => {
isLoading.value = true
try {
- await props.server.general?.reinstall(
- true,
- selectedLoader.value,
- selectedMCVersion.value,
- selectedLoader.value === 'Vanilla' ? '' : selectedLoaderVersion.value,
+ await client.archon.servers_v0.reinstall(
+ serverId,
+ {
+ loader: selectedLoader.value,
+ loader_version:
+ selectedLoader.value === 'Vanilla' ? undefined : selectedLoaderVersion.value || undefined,
+ game_version: selectedMCVersion.value,
+ },
props.initialSetup ? true : hardReset.value,
)
@@ -507,7 +509,7 @@ const handleReinstall = async () => {
}
const onShow = () => {
- selectedMCVersion.value = props.server.general?.mc_version || ''
+ selectedMCVersion.value = server.value?.mc_version || ''
if (isSnapshotSelected.value) {
showSnapshots.value = true
}
@@ -530,7 +532,7 @@ const show = (loader: Loaders) => {
selectedLoaderVersion.value = ''
}
selectedLoader.value = loader
- selectedMCVersion.value = props.server.general?.mc_version || ''
+ selectedMCVersion.value = server.value?.mc_version || ''
versionSelectModal.value?.show()
}
const hide = () => versionSelectModal.value?.hide()
diff --git a/apps/frontend/src/components/ui/servers/SaveBanner.vue b/apps/frontend/src/components/ui/servers/SaveBanner.vue
index 91f73f917f..d06b16f6bf 100644
--- a/apps/frontend/src/components/ui/servers/SaveBanner.vue
+++ b/apps/frontend/src/components/ui/servers/SaveBanner.vue
@@ -30,9 +30,7 @@
diff --git a/apps/frontend/src/components/ui/servers/ServerInstallation.vue b/apps/frontend/src/components/ui/servers/ServerInstallation.vue
deleted file mode 100644
index 0168d4f203..0000000000
--- a/apps/frontend/src/components/ui/servers/ServerInstallation.vue
+++ /dev/null
@@ -1,286 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
Modpack
-
- Update available
-
-
-
-
-
- Import .mrpack
-
-
-
-
-
-
-
- Switch modpack
-
-
-
-
- Switch modpack
-
-
-
-
-
-
-
Something went wrong while loading your modpack.
-
- {{ versionsError || currentVersionError }}
-
-
- Retry
-
-
-
-
-
-
-
-
- Change version
-
-
-
-
-
-
-
-
- Find a modpack
-
-
- or
-
-
- Upload .mrpack file
-
-
-
-
-
-
-
-
Platform
-
Your server's platform is the software that runs mods and plugins.
-
-
-
- The current platform was automatically selected based on your modpack.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/ServerSidebar.vue b/apps/frontend/src/components/ui/servers/ServerSidebar.vue
index 32534c0a52..1f517a9645 100644
--- a/apps/frontend/src/components/ui/servers/ServerSidebar.vue
+++ b/apps/frontend/src/components/ui/servers/ServerSidebar.vue
@@ -24,12 +24,7 @@
-
+
@@ -38,7 +33,6 @@
import { RightArrowIcon } from '@modrinth/assets'
import type { RouteLocationNormalized } from 'vue-router'
-import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
const emit = defineEmits(['reinstall'])
@@ -52,7 +46,6 @@ defineProps<{
shown?: boolean
}[]
route: RouteLocationNormalized
- server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts
index 2536c545ed..08e3ea4c2a 100644
--- a/apps/frontend/src/composables/featureFlags.ts
+++ b/apps/frontend/src/composables/featureFlags.ts
@@ -43,6 +43,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
hidePreviewBanner: false,
i18nDebug: false,
showDiscoverProjectButtons: false,
+ useV1ContentTabAPI: true,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
diff --git a/apps/frontend/src/composables/servers/modrinth-servers.ts b/apps/frontend/src/composables/servers/modrinth-servers.ts
deleted file mode 100644
index 2b02457a28..0000000000
--- a/apps/frontend/src/composables/servers/modrinth-servers.ts
+++ /dev/null
@@ -1,287 +0,0 @@
-import type { AbstractWebNotificationManager } from '@modrinth/ui'
-import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
-import { ModrinthServerError } from '@modrinth/utils'
-
-import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts'
-import { useServersFetch } from './servers-fetch.ts'
-
-export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
- if (err instanceof ModrinthServerError && err.v1Error) {
- notifications.addNotification({
- title: err.v1Error?.context ?? `An error occurred`,
- type: 'error',
- text: err.v1Error.description,
- errorCode: err.v1Error.error,
- })
- } else {
- notifications.addNotification({
- title: 'An error occurred',
- type: 'error',
- text: err.message ?? (err.data ? err.data.description : err),
- })
- }
-}
-
-export class ModrinthServer {
- readonly serverId: string
- private errors: Partial
> = {}
-
- readonly general: GeneralModule
- readonly content: ContentModule
- readonly network: NetworkModule
- readonly startup: StartupModule
-
- constructor(serverId: string) {
- this.serverId = serverId
-
- this.general = new GeneralModule(this)
- this.content = new ContentModule(this)
- this.network = new NetworkModule(this)
- this.startup = new StartupModule(this)
- }
-
- async fetchConfigFile(fileName: string): Promise {
- return await useServersFetch(`servers/${this.serverId}/config/${fileName}`)
- }
-
- constructServerProperties(properties: any): string {
- let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
-
- for (const [key, value] of Object.entries(properties)) {
- if (typeof value === 'object') {
- fileContent += `${key}=${JSON.stringify(value)}\n`
- } else if (typeof value === 'boolean') {
- fileContent += `${key}=${value ? 'true' : 'false'}\n`
- } else {
- fileContent += `${key}=${value}\n`
- }
- }
-
- return fileContent
- }
-
- async processImage(iconUrl: string | undefined): Promise {
- const sharedImage = useState(`server-icon-${this.serverId}`)
-
- if (sharedImage.value) {
- return sharedImage.value
- }
-
- try {
- const auth = await useServersFetch(`servers/${this.serverId}/fs`)
- try {
- const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
- override: auth,
- retry: 1, // Reduce retries for optional resources
- })
-
- if (fileData instanceof Blob && import.meta.client) {
- const dataURL = await new Promise((resolve) => {
- const canvas = document.createElement('canvas')
- const ctx = canvas.getContext('2d')
- const img = new Image()
- img.onload = () => {
- canvas.width = 512
- canvas.height = 512
- ctx?.drawImage(img, 0, 0, 512, 512)
- const dataURL = canvas.toDataURL('image/png')
- sharedImage.value = dataURL
- resolve(dataURL)
- URL.revokeObjectURL(img.src)
- }
- img.src = URL.createObjectURL(fileData)
- })
- return dataURL
- }
- } catch (error) {
- if (error instanceof ModrinthServerError) {
- if (error.statusCode && error.statusCode >= 500) {
- console.debug('Service unavailable, skipping icon processing')
- sharedImage.value = undefined
- return undefined
- }
-
- if (error.statusCode === 404 && iconUrl) {
- try {
- const response = await fetch(iconUrl)
- if (!response.ok) throw new Error('Failed to fetch icon')
- const file = await response.blob()
- const originalFile = new File([file], 'server-icon-original.png', {
- type: 'image/png',
- })
-
- if (import.meta.client) {
- const dataURL = await new Promise((resolve) => {
- const canvas = document.createElement('canvas')
- const ctx = canvas.getContext('2d')
- const img = new Image()
- img.onload = () => {
- canvas.width = 64
- canvas.height = 64
- ctx?.drawImage(img, 0, 0, 64, 64)
- canvas.toBlob(async (blob) => {
- if (blob) {
- const scaledFile = new File([blob], 'server-icon.png', {
- type: 'image/png',
- })
- await useServersFetch(`/create?path=/server-icon.png&type=file`, {
- method: 'POST',
- contentType: 'application/octet-stream',
- body: scaledFile,
- override: auth,
- })
- await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
- method: 'POST',
- contentType: 'application/octet-stream',
- body: originalFile,
- override: auth,
- })
- }
- }, 'image/png')
- const dataURL = canvas.toDataURL('image/png')
- sharedImage.value = dataURL
- resolve(dataURL)
- URL.revokeObjectURL(img.src)
- }
- img.src = URL.createObjectURL(file)
- })
- return dataURL
- }
- } catch (externalError: any) {
- console.debug('Could not process external icon:', externalError.message)
- }
- }
- } else {
- throw error
- }
- }
- } catch (error: any) {
- console.debug('Icon processing failed:', error.message)
- }
-
- sharedImage.value = undefined
- return undefined
- }
-
- async testNodeReachability(): Promise {
- if (!this.general?.node?.instance) {
- console.warn('No node instance available for ping test')
- return false
- }
-
- const wsUrl = `wss://${this.general.node.instance}/pingtest`
-
- try {
- return await new Promise((resolve) => {
- const socket = new WebSocket(wsUrl)
- const timeout = setTimeout(() => {
- socket.close()
- resolve(false)
- }, 5000)
-
- socket.onopen = () => {
- clearTimeout(timeout)
- socket.send(performance.now().toString())
- }
-
- socket.onmessage = () => {
- clearTimeout(timeout)
- socket.close()
- resolve(true)
- }
-
- socket.onerror = () => {
- clearTimeout(timeout)
- resolve(false)
- }
- })
- } catch (error) {
- console.error(`Failed to ping node ${wsUrl}:`, error)
- return false
- }
- }
-
- async refresh(
- modules: ModuleName[] = [],
- options?: {
- preserveConnection?: boolean
- preserveInstallState?: boolean
- },
- ): Promise {
- const modulesToRefresh =
- modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[])
-
- for (const module of modulesToRefresh) {
- this.errors[module] = undefined
-
- try {
- switch (module) {
- case 'general': {
- if (options?.preserveConnection) {
- const currentImage = this.general.image
- const currentMotd = this.general.motd
- const currentStatus = this.general.status
-
- await this.general.fetch()
-
- if (currentImage) {
- this.general.image = currentImage
- }
- if (currentMotd) {
- this.general.motd = currentMotd
- }
- if (options.preserveInstallState && currentStatus === 'installing') {
- this.general.status = 'installing'
- }
- } else {
- await this.general.fetch()
- }
- break
- }
- case 'content':
- await this.content.fetch()
- break
- case 'network':
- await this.network.fetch()
- break
- case 'startup':
- await this.startup.fetch()
- break
- }
- } catch (error) {
- if (error instanceof ModrinthServerError) {
- if (error.statusCode === 404 && module === 'content') {
- console.debug(`Optional ${module} resource not found:`, error.message)
- continue
- }
-
- if (error.statusCode && error.statusCode >= 500) {
- console.debug(`Temporary ${module} unavailable:`, error.message)
- continue
- }
- }
-
- this.errors[module] = {
- error:
- error instanceof ModrinthServerError
- ? error
- : new ModrinthServerError('Unknown error', undefined, error as Error),
- timestamp: Date.now(),
- }
- }
- }
- }
-
- get moduleErrors() {
- return this.errors
- }
-}
-
-export const useModrinthServers = async (
- serverId: string,
- includedModules: ModuleName[] = ['general'],
-) => {
- const server = new ModrinthServer(serverId)
- await server.refresh(includedModules)
- return reactive(server)
-}
diff --git a/apps/frontend/src/composables/servers/modules/backups.ts b/apps/frontend/src/composables/servers/modules/backups.ts
deleted file mode 100644
index 921b9a350e..0000000000
--- a/apps/frontend/src/composables/servers/modules/backups.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import type { AutoBackupSettings, Backup } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class BackupsModule extends ServerModule {
- data: Backup[] = []
-
- async fetch(): Promise {
- this.data = await useServersFetch(`servers/${this.serverId}/backups`, {}, 'backups')
- }
-
- async create(backupName: string): Promise {
- const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`
- const tempBackup: Backup = {
- id: tempId,
- name: backupName,
- created_at: new Date().toISOString(),
- locked: false,
- automated: false,
- interrupted: false,
- ongoing: true,
- task: { create: { progress: 0, state: 'ongoing' } },
- }
- this.data.push(tempBackup)
-
- try {
- const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
- method: 'POST',
- body: { name: backupName },
- })
-
- const backup = this.data.find((b) => b.id === tempId)
- if (backup) {
- backup.id = response.id
- }
-
- return response.id
- } catch (error) {
- this.data = this.data.filter((b) => b.id !== tempId)
- throw error
- }
- }
-
- async rename(backupId: string, newName: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/rename`, {
- method: 'POST',
- body: { name: newName },
- })
- await this.fetch()
- }
-
- async delete(backupId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
- method: 'DELETE',
- })
- await this.fetch()
- }
-
- async restore(backupId: string): Promise {
- const backup = this.data.find((b) => b.id === backupId)
- if (backup) {
- if (!backup.task) backup.task = {}
- backup.task.restore = { progress: 0, state: 'ongoing' }
- }
-
- try {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
- method: 'POST',
- })
- } catch (error) {
- if (backup?.task?.restore) {
- delete backup.task.restore
- }
- throw error
- }
- }
-
- async lock(backupId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
- method: 'POST',
- })
- await this.fetch()
- }
-
- async unlock(backupId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
- method: 'POST',
- })
- await this.fetch()
- }
-
- async retry(backupId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/backups/${backupId}/retry`, {
- method: 'POST',
- })
- }
-
- async updateAutoBackup(autoBackup: 'enable' | 'disable', interval: number): Promise {
- await useServersFetch(`servers/${this.serverId}/autobackup`, {
- method: 'POST',
- body: { set: autoBackup, interval },
- })
- }
-
- async getAutoBackup(): Promise {
- return await useServersFetch(`servers/${this.serverId}/autobackup`)
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/base.ts b/apps/frontend/src/composables/servers/modules/base.ts
deleted file mode 100644
index 151fadf231..0000000000
--- a/apps/frontend/src/composables/servers/modules/base.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { ModrinthServer } from '../modrinth-servers.ts'
-
-export abstract class ServerModule {
- protected server: ModrinthServer
-
- constructor(server: ModrinthServer) {
- this.server = server
- }
-
- protected get serverId(): string {
- return this.server.serverId
- }
-
- abstract fetch(): Promise
-}
diff --git a/apps/frontend/src/composables/servers/modules/content.ts b/apps/frontend/src/composables/servers/modules/content.ts
deleted file mode 100644
index 2db34b7691..0000000000
--- a/apps/frontend/src/composables/servers/modules/content.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { ContentType, Mod } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class ContentModule extends ServerModule {
- data: Mod[] = []
-
- async fetch(): Promise {
- const mods = await useServersFetch(`servers/${this.serverId}/mods`, {}, 'content')
- this.data = mods.sort((a, b) => (a?.name ?? '').localeCompare(b?.name ?? ''))
- }
-
- async install(contentType: ContentType, projectId: string, versionId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/mods`, {
- method: 'POST',
- body: {
- rinth_ids: { project_id: projectId, version_id: versionId },
- install_as: contentType,
- },
- })
- }
-
- async remove(path: string): Promise {
- await useServersFetch(`servers/${this.serverId}/deleteMod`, {
- method: 'POST',
- body: { path },
- })
- }
-
- async reinstall(replace: string, projectId: string, versionId: string): Promise {
- await useServersFetch(`servers/${this.serverId}/mods/update`, {
- method: 'POST',
- body: { replace, project_id: projectId, version_id: versionId },
- })
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/general.ts b/apps/frontend/src/composables/servers/modules/general.ts
deleted file mode 100644
index 77776b78d0..0000000000
--- a/apps/frontend/src/composables/servers/modules/general.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import type { JWTAuth, PowerAction, Project, ServerGeneral } from '@modrinth/utils'
-import { $fetch } from 'ofetch'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class GeneralModule extends ServerModule implements ServerGeneral {
- server_id!: string
- name!: string
- owner_id!: string
- net!: { ip: string; port: number; domain: string }
- game!: string
- backup_quota!: number
- used_backup_quota!: number
- status!: string
- suspension_reason!: string
- loader!: string
- loader_version!: string
- mc_version!: string
- upstream!: {
- kind: 'modpack' | 'mod' | 'resourcepack'
- version_id: string
- project_id: string
- } | null
-
- motd?: string
- image?: string
- project?: Project
- sftp_username!: string
- sftp_password!: string
- sftp_host!: string
- datacenter?: string
- notices?: any[]
- node!: { token: string; instance: string }
- flows?: { intro?: boolean }
-
- is_medal?: boolean
-
- async fetch(): Promise {
- const data = await useServersFetch(`servers/${this.serverId}`, {}, 'general')
-
- if (data.upstream?.project_id) {
- const project = await $fetch(
- `https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
- )
- data.project = project as Project
- }
-
- if (import.meta.client) {
- data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined
- }
-
- // Copy data to this module
- Object.assign(this, data)
- }
-
- async updateName(newName: string): Promise {
- await useServersFetch(`servers/${this.serverId}/name`, {
- method: 'POST',
- body: { name: newName },
- })
- }
-
- async power(action: PowerAction): Promise {
- await useServersFetch(`servers/${this.serverId}/power`, {
- method: 'POST',
- body: { action },
- })
- await new Promise((resolve) => setTimeout(resolve, 1000))
- await this.fetch() // Refresh this module
- }
-
- async reinstall(
- loader: boolean,
- projectId: string,
- versionId?: string,
- loaderVersionId?: string,
- hardReset: boolean = false,
- ): Promise {
- const hardResetParam = hardReset ? 'true' : 'false'
- if (loader) {
- if (projectId.toLowerCase() === 'neoforge') {
- projectId = 'NeoForge'
- }
- await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
- method: 'POST',
- body: {
- loader: projectId,
- loader_version: loaderVersionId,
- game_version: versionId,
- },
- })
- } else {
- await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
- method: 'POST',
- body: { project_id: projectId, version_id: versionId },
- })
- }
- }
-
- reinstallFromMrpack(
- mrpack: File,
- hardReset: boolean = false,
- ): {
- promise: Promise
- onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void
- } {
- const hardResetParam = hardReset ? 'true' : 'false'
-
- const progressSubject = new EventTarget()
-
- const uploadPromise = (async () => {
- try {
- const auth = await useServersFetch(`servers/${this.serverId}/reinstallFromMrpack`)
-
- await new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest()
-
- xhr.upload.addEventListener('progress', (e) => {
- if (e.lengthComputable) {
- progressSubject.dispatchEvent(
- new CustomEvent('progress', {
- detail: {
- loaded: e.loaded,
- total: e.total,
- progress: (e.loaded / e.total) * 100,
- },
- }),
- )
- }
- })
-
- xhr.onload = () =>
- xhr.status >= 200 && xhr.status < 300
- ? resolve()
- : reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`))
-
- xhr.onerror = () => reject(new Error('[pyroservers] .mrpack upload failed'))
- xhr.onabort = () => reject(new Error('[pyroservers] .mrpack upload cancelled'))
- xhr.ontimeout = () => reject(new Error('[pyroservers] .mrpack upload timed out'))
- xhr.timeout = 30 * 60 * 1000
-
- xhr.open('POST', `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`)
- xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`)
-
- const formData = new FormData()
- formData.append('file', mrpack)
- xhr.send(formData)
- })
- } catch (err) {
- console.error('Error reinstalling from mrpack:', err)
- throw err
- }
- })()
-
- return {
- promise: uploadPromise,
- onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
- progressSubject.addEventListener('progress', ((e: CustomEvent) =>
- cb(e.detail)) as EventListener),
- }
- }
-
- async suspend(status: boolean): Promise {
- await useServersFetch(`servers/${this.serverId}/suspend`, {
- method: 'POST',
- body: { suspended: status },
- })
- }
-
- async endIntro(): Promise {
- await useServersFetch(`servers/${this.serverId}/flows/intro`, {
- method: 'DELETE',
- version: 1,
- })
- await this.fetch() // Refresh this module
- }
-
- async setMotd(motd: string): Promise {
- try {
- const props = (await this.server.fetchConfigFile('ServerProperties')) as any
- if (props) {
- props.motd = motd
- const newProps = this.server.constructServerProperties(props)
- const octetStream = new Blob([newProps], { type: 'application/octet-stream' })
- const auth = await useServersFetch(`servers/${this.serverId}/fs`)
-
- await useServersFetch(`/update?path=/server.properties`, {
- method: 'PUT',
- contentType: 'application/octet-stream',
- body: octetStream,
- override: auth,
- })
- }
- } catch {
- console.error(
- '[Modrinth Hosting] [General] Failed to set MOTD due to lack of server properties file.',
- )
- }
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/index.ts b/apps/frontend/src/composables/servers/modules/index.ts
deleted file mode 100644
index 62fe2c45f8..0000000000
--- a/apps/frontend/src/composables/servers/modules/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export * from './backups.ts'
-export * from './base.ts'
-export * from './content.ts'
-export * from './general.ts'
-export * from './network.ts'
-export * from './startup.ts'
-export * from './ws.ts'
diff --git a/apps/frontend/src/composables/servers/modules/network.ts b/apps/frontend/src/composables/servers/modules/network.ts
deleted file mode 100644
index d434f77001..0000000000
--- a/apps/frontend/src/composables/servers/modules/network.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { Allocation } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class NetworkModule extends ServerModule {
- allocations: Allocation[] = []
-
- async fetch(): Promise {
- this.allocations = await useServersFetch(
- `servers/${this.serverId}/allocations`,
- {},
- 'network',
- )
- }
-
- async reserveAllocation(name: string): Promise {
- return await useServersFetch(`servers/${this.serverId}/allocations?name=${name}`, {
- method: 'POST',
- })
- }
-
- async updateAllocation(port: number, name: string): Promise {
- await useServersFetch(`servers/${this.serverId}/allocations/${port}?name=${name}`, {
- method: 'PUT',
- })
- }
-
- async deleteAllocation(port: number): Promise {
- await useServersFetch(`servers/${this.serverId}/allocations/${port}`, {
- method: 'DELETE',
- })
- }
-
- async checkSubdomainAvailability(subdomain: string): Promise {
- const result = (await useServersFetch(`subdomains/${subdomain}/isavailable`)) as {
- available: boolean
- }
- return result.available
- }
-
- async changeSubdomain(subdomain: string): Promise {
- await useServersFetch(`servers/${this.serverId}/subdomain`, {
- method: 'POST',
- body: { subdomain },
- })
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/startup.ts b/apps/frontend/src/composables/servers/modules/startup.ts
deleted file mode 100644
index a47c031f69..0000000000
--- a/apps/frontend/src/composables/servers/modules/startup.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { JDKBuild, JDKVersion, Startup } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class StartupModule extends ServerModule implements Startup {
- invocation!: string
- original_invocation!: string
- jdk_version!: JDKVersion
- jdk_build!: JDKBuild
-
- async fetch(): Promise {
- const data = await useServersFetch(`servers/${this.serverId}/startup`, {}, 'startup')
- Object.assign(this, data)
- }
-
- async update(invocation: string, jdkVersion: JDKVersion, jdkBuild: JDKBuild): Promise {
- await useServersFetch(`servers/${this.serverId}/startup`, {
- method: 'POST',
- body: {
- invocation: invocation || null,
- jdk_version: jdkVersion || null,
- jdk_build: jdkBuild || null,
- },
- })
- }
-}
diff --git a/apps/frontend/src/composables/servers/modules/ws.ts b/apps/frontend/src/composables/servers/modules/ws.ts
deleted file mode 100644
index aa10a30294..0000000000
--- a/apps/frontend/src/composables/servers/modules/ws.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { JWTAuth } from '@modrinth/utils'
-
-import { useServersFetch } from '../servers-fetch.ts'
-import { ServerModule } from './base.ts'
-
-export class WSModule extends ServerModule implements JWTAuth {
- url!: string
- token!: string
-
- async fetch(): Promise {
- const data = await useServersFetch(`servers/${this.serverId}/ws`, {}, 'ws')
- Object.assign(this, data)
- }
-}
diff --git a/apps/frontend/src/composables/servers/use-server-image.ts b/apps/frontend/src/composables/servers/use-server-image.ts
new file mode 100644
index 0000000000..c613a79ae5
--- /dev/null
+++ b/apps/frontend/src/composables/servers/use-server-image.ts
@@ -0,0 +1,131 @@
+import type { Archon } from '@modrinth/api-client'
+import { injectModrinthClient } from '@modrinth/ui'
+import { type ComputedRef, ref, watch } from 'vue'
+
+// TODO: Remove and use V1 when available
+export function useServerImage(
+ serverId: string,
+ upstream: ComputedRef,
+) {
+ const client = injectModrinthClient()
+ const image = ref()
+
+ const sharedImage = useState(`server-icon-${serverId}`)
+ if (sharedImage.value) {
+ image.value = sharedImage.value
+ }
+
+ async function loadImage() {
+ if (sharedImage.value) {
+ image.value = sharedImage.value
+ return
+ }
+
+ if (import.meta.server) return
+
+ const cached = localStorage.getItem(`server-icon-${serverId}`)
+ if (cached) {
+ sharedImage.value = cached
+ image.value = cached
+ return
+ }
+
+ let projectIconUrl: string | undefined
+ const upstreamVal = upstream.value
+ if (upstreamVal?.project_id) {
+ try {
+ const project = await $fetch<{ icon_url?: string }>(
+ `https://api.modrinth.com/v2/project/${upstreamVal.project_id}`,
+ )
+ projectIconUrl = project.icon_url
+ } catch {
+ // project fetch failed, continue without icon url
+ }
+ }
+
+ try {
+ const fileData = await client.kyros.files_v0.downloadFile('/server-icon-original.png')
+
+ if (fileData instanceof Blob) {
+ const dataURL = await resizeImage(fileData, 512)
+ sharedImage.value = dataURL
+ localStorage.setItem(`server-icon-${serverId}`, dataURL)
+ image.value = dataURL
+ return
+ }
+ } catch (error: any) {
+ if (error?.statusCode >= 500) {
+ image.value = undefined
+ return
+ }
+
+ if (error?.statusCode === 404 && projectIconUrl) {
+ try {
+ const response = await fetch(projectIconUrl)
+ if (!response.ok) throw new Error('Failed to fetch icon')
+ const file = await response.blob()
+ const originalFile = new File([file], 'server-icon-original.png', {
+ type: 'image/png',
+ })
+
+ const dataURL = await new Promise((resolve) => {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ const img = new Image()
+ img.onload = () => {
+ canvas.width = 64
+ canvas.height = 64
+ ctx?.drawImage(img, 0, 0, 64, 64)
+ canvas.toBlob(async (blob) => {
+ if (blob) {
+ const scaledFile = new File([blob], 'server-icon.png', {
+ type: 'image/png',
+ })
+ client.kyros.files_v0
+ .uploadFile('/server-icon.png', scaledFile)
+ .promise.catch(() => {})
+ client.kyros.files_v0
+ .uploadFile('/server-icon-original.png', originalFile)
+ .promise.catch(() => {})
+ }
+ }, 'image/png')
+ const result = canvas.toDataURL('image/png')
+ sharedImage.value = result
+ localStorage.setItem(`server-icon-${serverId}`, result)
+ resolve(result)
+ URL.revokeObjectURL(img.src)
+ }
+ img.src = URL.createObjectURL(file)
+ })
+ image.value = dataURL
+ return
+ } catch (externalError: any) {
+ console.debug('Could not process external icon:', externalError.message)
+ }
+ }
+ }
+
+ image.value = undefined
+ }
+
+ watch(upstream, () => loadImage(), { immediate: true })
+
+ return image
+}
+
+function resizeImage(blob: Blob, size: number): Promise {
+ return new Promise((resolve) => {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ const img = new Image()
+ img.onload = () => {
+ canvas.width = size
+ canvas.height = size
+ ctx?.drawImage(img, 0, 0, size, size)
+ const dataURL = canvas.toDataURL('image/png')
+ resolve(dataURL)
+ URL.revokeObjectURL(img.src)
+ }
+ img.src = URL.createObjectURL(blob)
+ })
+}
diff --git a/apps/frontend/src/composables/servers/use-server-project.ts b/apps/frontend/src/composables/servers/use-server-project.ts
new file mode 100644
index 0000000000..eeb22a8b52
--- /dev/null
+++ b/apps/frontend/src/composables/servers/use-server-project.ts
@@ -0,0 +1,17 @@
+import type { Archon } from '@modrinth/api-client'
+import type { Project } from '@modrinth/utils'
+import { useQuery } from '@tanstack/vue-query'
+import { $fetch } from 'ofetch'
+import { computed, type ComputedRef } from 'vue'
+
+// TODO: Remove and use v1
+export function useServerProject(
+ upstream: ComputedRef,
+) {
+ return useQuery({
+ queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]),
+ queryFn: () =>
+ $fetch(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`),
+ enabled: computed(() => !!upstream.value?.project_id),
+ })
+}
diff --git a/apps/frontend/src/pages/[type]/[id]/settings/members.vue b/apps/frontend/src/pages/[type]/[id]/settings/members.vue
index a80d7cb676..962b1f10d8 100644
--- a/apps/frontend/src/pages/[type]/[id]/settings/members.vue
+++ b/apps/frontend/src/pages/[type]/[id]/settings/members.vue
@@ -5,7 +5,6 @@
title="Are you sure you want to remove this project from the organization?"
description="If you proceed, this project will no longer be managed by the organization."
proceed-label="Remove"
- :noblur="!(cosmetics?.advancedRendering ?? true)"
@proceed="onRemoveFromOrg"
/>
@@ -352,7 +351,7 @@
-
- {{
- formatMessage(messages.noTransactions)
- }}
- {{
- formatMessage(messages.noTransactionsDesc)
- }}
-
+
-
+
-
- {{ server.general.loader }} {{ server.general.mc_version }}
+ {{ serverData.loader }} {{ serverData.mc_version }}
-
+
-
+
- Back to server
-
+ {{
+ fromContext === 'onboarding'
+ ? 'Back to setup'
+ : fromContext === 'reset-server'
+ ? 'Cancel reset'
+ : 'Back to server'
+ }}
+
-
+
}"
aria-label="Filters"
>
-
+
-
-
Options
-
-
- Erase all data on install
-
-
-
- If enabled, existing mods, worlds, and configurations, will be deleted before installing
- the selected modpack.
-
-
-
@update:model-value="updateSearchResults()"
/>
-
-
-
- {{ filterType.formatted_name }}
-
-
-
-
-
-
- {{ filter.formatted_name }}
-
-
-
-
- {{ formatMessage(messages.gameVersionShaderMessage) }}
-
-
-
- {{ formatMessage(messages.gameVersionProvidedByServer) }}
-
-
- {{ formatMessage(messages.modLoaderProvidedByServer) }}
-
- {{ formatMessage(messages.syncFilterButton) }}
-
-
+
+
+ {{ filter.formatted_name }}
+
+
+
+
+ {{ formatMessage(messages.gameVersionShaderMessage) }}
+
+
+
+ {{ formatMessage(messages.gameVersionProvidedByServer) }}
+
+
+ {{ formatMessage(messages.modLoaderProvidedByServer) }}
+
+ {{ formatMessage(messages.syncFilterButton) }}
+
@@ -727,10 +773,10 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@@ -776,14 +822,6 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
/>
-
:provided-message="messages.providedByServer"
/>
-
+
No results found for your query!
@@ -808,37 +839,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
>
-
+
-
-
-
-
:environment="
['mod', 'modpack'].includes(currentType)
? {
- clientSide: result.client_side as Labrinth.Projects.v2.Environment,
- serverSide: result.server_side as Labrinth.Projects.v2.Environment,
+ clientSide: result.client_side,
+ serverSide: result.server_side,
}
: undefined
"
@@ -869,7 +871,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
-
+
@@ -893,16 +895,16 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
-
+
@@ -933,6 +935,18 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
+
+
{}"
+ @create="onModpackFlowCreate"
+ />
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/index.vue
index 4d39cbff51..dbea1bbf87 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/index.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/index.vue
@@ -1,78 +1,55 @@
-
-
-
-
-
-
-
- {{ serverData?.name }} shut down unexpectedly. We've automatically analyzed the logs
- and found the following problems:
-
-
-
-
- {{ problem.message }}
-
-
-
- {{ solution.message }}
-
-
-
-
-
-
-
-
-
-
{{ serverData?.name }} shut down unexpectedly.
-
-
- The server stopped because it ran out of memory. There may be a memory leak caused
- by a mod or plugin, or you may need to upgrade your Modrinth Server.
-
-
- We could not automatically determine the specific cause of the crash, but your
- server exited with code
- {{ props.powerStateDetails.exit_code }}.
- {{
- props.powerStateDetails.exit_code === 1
- ? 'There may be a mod or plugin causing the issue, or an issue with your server configuration.'
- : ''
- }}
-
-
We could not determine the specific cause of the crash.
-
You can try restarting the server.
-
-
-
-
-
-
-
-
{{ serverData?.name }} shut down unexpectedly.
-
- We could not find any specific problems, but you can try restarting the server.
-
+
+
+ We automatically analyzed the logs and found the following:
+
+
+
+
{{ problem.message }}
+
+
+ {{ solution.message }}
+
+
-
-
-
-
-
-
-
+
+
+
+ The server stopped because it ran out of memory. There may be a memory leak caused by a
+ mod or plugin, or you may need to upgrade your Modrinth Server.
+
+
+ Your server exited with code {{ props.powerStateDetails.exit_code }}.
+
+ There may be a mod or plugin causing the issue, or an issue with your server
+ configuration.
+
+
+ We could not determine the specific cause of the crash.
+ You can try restarting the server.
+
+
+ We could not find any specific problems, but you can try restarting the server.
+
+
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue
index 397e67402e..f9fd5f60eb 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue
@@ -58,7 +58,7 @@
@@ -72,10 +72,10 @@
We couldn't load your server's network settings. Here's what we know:
{{
- JSON.stringify(server.moduleErrors.network.error)
+ allocationsError?.message ?? 'Unknown error'
}}
-
server.refresh(['network'])">
+ refetchAllocations()">
Retry
@@ -249,7 +249,7 @@
()
+const { server, serverId } = injectModrinthServerContext()
+const client = injectModrinthClient()
+const queryClient = useQueryClient()
const isUpdating = ref(false)
-const data = computed(() => props.server.general)
+const data = server
const serverIP = ref(data?.value?.net?.ip ?? '')
const serverSubdomain = ref(data?.value?.net?.domain ?? '')
@@ -296,8 +298,15 @@ const serverPrimaryPort = ref(data?.value?.net?.port ?? 0)
const userDomain = ref('')
const exampleDomain = 'play.example.com'
-const network = computed(() => props.server.network)
-const allocations = computed(() => network.value?.allocations)
+const {
+ data: allocationsData,
+ error: allocationsError,
+ refetch: refetchAllocations,
+} = useQuery({
+ queryKey: ['servers', 'allocations', serverId] as const,
+ queryFn: () => client.archon.servers_v0.getAllocations(serverId),
+})
+const allocations = allocationsData
const newAllocationModal = ref()
const editAllocationModal = ref()
@@ -316,8 +325,8 @@ const addNewAllocation = async () => {
if (!newAllocationName.value) return
try {
- await props.server.network?.reserveAllocation(newAllocationName.value)
- await props.server.refresh(['network'])
+ await client.archon.servers_v0.reserveAllocation(serverId, newAllocationName.value)
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
newAllocationModal.value?.hide()
newAllocationName.value = ''
@@ -360,8 +369,8 @@ const showConfirmDeleteModal = (port: number) => {
const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return
- await props.server.network?.deleteAllocation(allocationToDelete.value)
- await props.server.refresh(['network'])
+ await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
@@ -376,8 +385,12 @@ const editAllocation = async () => {
if (!newAllocationName.value) return
try {
- await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value)
- await props.server.refresh(['network'])
+ await client.archon.servers_v0.updateAllocation(
+ serverId,
+ newAllocationPort.value,
+ newAllocationName.value,
+ )
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
editAllocationModal.value?.hide()
newAllocationName.value = ''
@@ -397,7 +410,8 @@ const saveNetwork = async () => {
try {
isUpdating.value = true
- const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value)
+ const result = await client.archon.servers_v0.checkSubdomainAvailability(serverSubdomain.value)
+ const available = result.available
if (!available) {
addNotification({
type: 'error',
@@ -407,13 +421,18 @@ const saveNetwork = async () => {
return
}
if (serverSubdomain.value !== data?.value?.net?.domain) {
- await props.server.network?.changeSubdomain(serverSubdomain.value)
+ await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
- await props.server.network?.updateAllocation(serverPrimaryPort.value, newAllocationName.value)
+ await client.archon.servers_v0.updateAllocation(
+ serverId,
+ serverPrimaryPort.value,
+ newAllocationName.value,
+ )
}
await new Promise((resolve) => setTimeout(resolve, 500))
- await props.server.refresh()
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
+ await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
title: 'Server settings updated',
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue
index 4102a6b5c7..d3c84bf5b0 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue
@@ -32,7 +32,7 @@
()
-
const preferences = {
ramAsNumber: {
displayName: 'RAM as bytes',
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue
index 4d2d524038..4dc0d63606 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue
@@ -1,9 +1,11 @@
-
+
+
Server properties
@@ -22,7 +24,7 @@
- Search server properties
+ Search server properties
-
- {{ formatPropertyName(index) }}
-
-
-
-
+
{{ formatPropertyName(key) }}
+
-
-
-
- The server properties file has not been generated yet. Start up your server to generate it.
-
+
+
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue
index 6f90633822..c48452832c 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue
@@ -1,32 +1,6 @@
-
-
-
-
-
-
-
-
Failed to load startup settings
-
-
- We couldn't load your server's startup settings. Here's what we know:
-
-
- {{
- JSON.stringify(server.moduleErrors.startup.error)
- }}
-
-
server.refresh(['startup'])">
- Retry
-
-
-
-
-
+
@@ -42,7 +16,7 @@
@@ -51,13 +25,22 @@
-
+
@@ -70,168 +53,203 @@
different Java version to work properly.
-
-
-
Show all Java versions
+
+
+
+
+
+
+ {{ showAllVersions ? 'Hide extra versions' : 'Show all versions' }}
+
+
+
+
+
+
-
Runtime
The Java runtime your server will use.
-
+
diff --git a/apps/frontend/src/providers/setup.ts b/apps/frontend/src/providers/setup.ts
new file mode 100644
index 0000000000..c558dc4c79
--- /dev/null
+++ b/apps/frontend/src/providers/setup.ts
@@ -0,0 +1,16 @@
+import { provideNotificationManager } from '@modrinth/ui'
+
+import { FrontendNotificationManager } from './frontend-notifications'
+import { setupFilePickerProvider } from './setup/file-picker'
+import { setupModrinthClientProvider } from './setup/modrinth-client'
+import { setupPageContextProvider } from './setup/page-context'
+import { setupTagsProvider } from './setup/tags'
+
+export function setupProviders(auth: Awaited
>) {
+ provideNotificationManager(new FrontendNotificationManager())
+
+ setupModrinthClientProvider(auth)
+ setupTagsProvider()
+ setupFilePickerProvider()
+ setupPageContextProvider()
+}
diff --git a/apps/frontend/src/providers/setup/file-picker.ts b/apps/frontend/src/providers/setup/file-picker.ts
new file mode 100644
index 0000000000..9d16e279f9
--- /dev/null
+++ b/apps/frontend/src/providers/setup/file-picker.ts
@@ -0,0 +1,23 @@
+import { provideFilePicker } from '@modrinth/ui'
+
+function pickFile(accept: string): Promise<{ file: File; previewUrl: string } | null> {
+ return new Promise((resolve) => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = accept
+ input.onchange = () => {
+ const file = input.files?.[0]
+ if (!file) return resolve(null)
+ resolve({ file, previewUrl: URL.createObjectURL(file) })
+ }
+ input.oncancel = () => resolve(null)
+ input.click()
+ })
+}
+
+export function setupFilePickerProvider() {
+ provideFilePicker({
+ pickImage: () => pickFile('image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/gif'),
+ pickModpackFile: () => pickFile('.mrpack,application/x-modrinth-modpack+zip,application/zip'),
+ })
+}
diff --git a/apps/frontend/src/providers/setup/modrinth-client.ts b/apps/frontend/src/providers/setup/modrinth-client.ts
new file mode 100644
index 0000000000..0f5d13ede0
--- /dev/null
+++ b/apps/frontend/src/providers/setup/modrinth-client.ts
@@ -0,0 +1,14 @@
+import { provideModrinthClient } from '@modrinth/ui'
+
+import { createModrinthClient } from '~/helpers/api.ts'
+
+export function setupModrinthClientProvider(auth: Awaited>) {
+ const config = useRuntimeConfig()
+ const client = createModrinthClient(auth, {
+ apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
+ archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
+ rateLimitKey: config.rateLimitKey,
+ })
+ provideModrinthClient(client)
+ return client
+}
diff --git a/apps/frontend/src/providers/setup/page-context.ts b/apps/frontend/src/providers/setup/page-context.ts
new file mode 100644
index 0000000000..dd5d0ef5ac
--- /dev/null
+++ b/apps/frontend/src/providers/setup/page-context.ts
@@ -0,0 +1,14 @@
+import { provideModalBehavior, providePageContext } from '@modrinth/ui'
+import { computed, ref } from 'vue'
+
+export function setupPageContextProvider() {
+ const cosmetics = useCosmetics()
+
+ providePageContext({
+ hierarchicalSidebarAvailable: ref(false),
+ showAds: ref(false),
+ })
+ provideModalBehavior({
+ noblur: computed(() => !(cosmetics.value?.advancedRendering ?? true)),
+ })
+}
diff --git a/apps/frontend/src/providers/setup/tags.ts b/apps/frontend/src/providers/setup/tags.ts
new file mode 100644
index 0000000000..3ea7aa2653
--- /dev/null
+++ b/apps/frontend/src/providers/setup/tags.ts
@@ -0,0 +1,10 @@
+import { provideTags } from '@modrinth/ui'
+import { computed } from 'vue'
+
+export function setupTagsProvider() {
+ const generatedState = useGeneratedState()
+ provideTags({
+ gameVersions: computed(() => generatedState.value.gameVersions),
+ loaders: computed(() => generatedState.value.loaders),
+ })
+}
diff --git a/apps/frontend/wrangler.jsonc b/apps/frontend/wrangler.jsonc
index 5239054eb4..1502e7bdab 100644
--- a/apps/frontend/wrangler.jsonc
+++ b/apps/frontend/wrangler.jsonc
@@ -50,8 +50,8 @@
"vars": {
"ENVIRONMENT": "staging",
"SENTRY_ENVIRONMENT": "staging",
- "BASE_URL": "https://staging-api.modrinth.com/v2/",
- "BROWSER_BASE_URL": "https://staging-api.modrinth.com/v2/",
+ "BASE_URL": "https://api.modrinth.com/v2/",
+ "BROWSER_BASE_URL": "https://api.modrinth.com/v2/",
"PYRO_BASE_URL": "https://staging-archon.modrinth.com/",
"STRIPE_PUBLISHABLE_KEY": "pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b"
},
diff --git a/package.json b/package.json
index 3953ee543f..315a0e3ff0 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@modrinth/tooling-config": "workspace:*",
+ "@tailwindcss/container-queries": "^0.1.1",
"@types/node": "^20.1.0",
"@vue/compiler-dom": "^3.5.26",
"@vue/compiler-sfc": "^3.5.26",
diff --git a/packages/api-client/src/features/node-auth.ts b/packages/api-client/src/features/node-auth.ts
index cee76feb78..62424d5d09 100644
--- a/packages/api-client/src/features/node-auth.ts
+++ b/packages/api-client/src/features/node-auth.ts
@@ -8,6 +8,8 @@ import type { RequestContext } from '../types/request'
export interface NodeAuth {
/** Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs") */
url: string
+ /** Base URL without path suffix (e.g., "node-xyz.modrinth.com") — used when available */
+ baseUrl?: string
/** JWT token */
token: string
}
@@ -105,7 +107,7 @@ export class NodeAuthFeature extends AbstractFeature {
}
private applyAuth(context: RequestContext, auth: NodeAuth): void {
- const baseUrl = `https://${auth.url.replace('v0/fs', '')}`
+ const baseUrl = `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}`
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
context.options.headers = {
diff --git a/packages/api-client/src/modules/archon/backups/v0.ts b/packages/api-client/src/modules/archon/backups/v0.ts
deleted file mode 100644
index e1e402faf7..0000000000
--- a/packages/api-client/src/modules/archon/backups/v0.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { AbstractModule } from '../../../core/abstract-module'
-import type { Archon } from '../types'
-
-export class ArchonBackupsV0Module extends AbstractModule {
- public getModuleID(): string {
- return 'archon_backups_v0'
- }
-
- /** GET /modrinth/v0/servers/:server_id/backups */
- public async list(serverId: string): Promise {
- return this.client.request(`/servers/${serverId}/backups`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'GET',
- })
- }
-
- /** GET /modrinth/v0/servers/:server_id/backups/:backup_id */
- public async get(serverId: string, backupId: string): Promise {
- return this.client.request(
- `/servers/${serverId}/backups/${backupId}`,
- { api: 'archon', version: 'modrinth/v0', method: 'GET' },
- )
- }
-
- /** POST /modrinth/v0/servers/:server_id/backups */
- public async create(
- serverId: string,
- request: Archon.Backups.v1.BackupRequest,
- ): Promise {
- return this.client.request(
- `/servers/${serverId}/backups`,
- { api: 'archon', version: 'modrinth/v0', method: 'POST', body: request },
- )
- }
-
- /** POST /modrinth/v0/servers/:server_id/backups/:backup_id/restore */
- public async restore(serverId: string, backupId: string): Promise {
- await this.client.request(`/servers/${serverId}/backups/${backupId}/restore`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- })
- }
-
- /** DELETE /modrinth/v0/servers/:server_id/backups/:backup_id */
- public async delete(serverId: string, backupId: string): Promise {
- await this.client.request(`/servers/${serverId}/backups/${backupId}`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'DELETE',
- })
- }
-
- /** POST /modrinth/v0/servers/:server_id/backups/:backup_id/retry */
- public async retry(serverId: string, backupId: string): Promise {
- await this.client.request(`/servers/${serverId}/backups/${backupId}/retry`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- })
- }
-
- /** PATCH /modrinth/v0/servers/:server_id/backups/:backup_id */
- public async rename(
- serverId: string,
- backupId: string,
- request: Archon.Backups.v1.PatchBackup,
- ): Promise {
- await this.client.request(`/servers/${serverId}/backups/${backupId}`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'PATCH',
- body: request,
- })
- }
-}
diff --git a/packages/api-client/src/modules/archon/backups/v1.ts b/packages/api-client/src/modules/archon/backups/v1.ts
index 65537876ee..d2d8da14e9 100644
--- a/packages/api-client/src/modules/archon/backups/v1.ts
+++ b/packages/api-client/src/modules/archon/backups/v1.ts
@@ -1,102 +1,84 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
-/**
- * Default world ID - Uuid::nil() which the backend treats as "first/active world"
- * See: apps/archon/src/routes/v1/servers/worlds/mod.rs - world_id_nullish()
- * TODO:
- * - Make sure world ID is being passed before we ship worlds.
- * - The schema will change when Backups v4 (routes stay as v1) so remember to do that.
- */
-const DEFAULT_WORLD_ID: string = '00000000-0000-0000-0000-000000000000' as const
-
export class ArchonBackupsV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_v1'
}
- /** GET /v1/:server_id/worlds/:world_id/backups */
- public async list(
- serverId: string,
- worldId: string = DEFAULT_WORLD_ID,
- ): Promise {
+ /** GET /v1/servers/:server_id/worlds/:world_id/backups */
+ public async list(serverId: string, worldId: string): Promise {
return this.client.request(
- `/${serverId}/worlds/${worldId}/backups`,
+ `/servers/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
- /** GET /v1/:server_id/worlds/:world_id/backups/:backup_id */
+ /** GET /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async get(
serverId: string,
+ worldId: string,
backupId: string,
- worldId: string = DEFAULT_WORLD_ID,
): Promise {
return this.client.request(
- `/${serverId}/worlds/${worldId}/backups/${backupId}`,
+ `/servers/${serverId}/worlds/${worldId}/backups/${backupId}`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
- /** POST /v1/:server_id/worlds/:world_id/backups */
+ /** POST /v1/servers/:server_id/worlds/:world_id/backups */
public async create(
serverId: string,
+ worldId: string,
request: Archon.Backups.v1.BackupRequest,
- worldId: string = DEFAULT_WORLD_ID,
): Promise {
return this.client.request(
- `/${serverId}/worlds/${worldId}/backups`,
+ `/servers/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
- /** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/restore */
- public async restore(
- serverId: string,
- backupId: string,
- worldId: string = DEFAULT_WORLD_ID,
- ): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}/restore`, {
- api: 'archon',
- version: 1,
- method: 'POST',
- })
+ /** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/restore */
+ public async restore(serverId: string, worldId: string, backupId: string): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/backups/${backupId}/restore`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ },
+ )
}
- /** DELETE /v1/:server_id/worlds/:world_id/backups/:backup_id */
- public async delete(
- serverId: string,
- backupId: string,
- worldId: string = DEFAULT_WORLD_ID,
- ): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
+ /** DELETE /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
+ public async delete(serverId: string, worldId: string, backupId: string): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'DELETE',
})
}
- /** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/retry */
- public async retry(
- serverId: string,
- backupId: string,
- worldId: string = DEFAULT_WORLD_ID,
- ): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}/retry`, {
- api: 'archon',
- version: 1,
- method: 'POST',
- })
+ /** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/retry */
+ public async retry(serverId: string, worldId: string, backupId: string): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/backups/${backupId}/retry`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ },
+ )
}
- /** PATCH /v1/:server_id/worlds/:world_id/backups/:backup_id */
+ /** PATCH /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async rename(
serverId: string,
+ worldId: string,
backupId: string,
request: Archon.Backups.v1.PatchBackup,
- worldId: string = DEFAULT_WORLD_ID,
): Promise {
- await this.client.request(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'PATCH',
diff --git a/packages/api-client/src/modules/archon/content/v0.ts b/packages/api-client/src/modules/archon/content/v0.ts
deleted file mode 100644
index 384fd38572..0000000000
--- a/packages/api-client/src/modules/archon/content/v0.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { AbstractModule } from '../../../core/abstract-module'
-import type { Archon } from '../types'
-
-export class ArchonContentV0Module extends AbstractModule {
- public getModuleID(): string {
- return 'archon_content_v0'
- }
-
- /** GET /modrinth/v0/servers/:server_id/mods */
- public async list(serverId: string): Promise {
- return this.client.request(`/servers/${serverId}/mods`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'GET',
- })
- }
-
- /** POST /modrinth/v0/servers/:server_id/mods */
- public async install(
- serverId: string,
- request: Archon.Content.v0.InstallModRequest,
- ): Promise {
- await this.client.request(`/servers/${serverId}/mods`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- body: request,
- })
- }
-
- /** POST /modrinth/v0/servers/:server_id/deleteMod */
- public async delete(
- serverId: string,
- request: Archon.Content.v0.DeleteModRequest,
- ): Promise {
- await this.client.request(`/servers/${serverId}/deleteMod`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- body: request,
- })
- }
-
- /** POST /modrinth/v0/servers/:server_id/mods/update */
- public async update(
- serverId: string,
- request: Archon.Content.v0.UpdateModRequest,
- ): Promise {
- await this.client.request(`/servers/${serverId}/mods/update`, {
- api: 'archon',
- version: 'modrinth/v0',
- method: 'POST',
- body: request,
- })
- }
-}
diff --git a/packages/api-client/src/modules/archon/content/v1.ts b/packages/api-client/src/modules/archon/content/v1.ts
new file mode 100644
index 0000000000..478b9d1934
--- /dev/null
+++ b/packages/api-client/src/modules/archon/content/v1.ts
@@ -0,0 +1,241 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Archon } from '../types'
+
+export class ArchonContentV1Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'archon_content_v1'
+ }
+
+ /** GET /v1/:server_id/worlds/:world_id/addons */
+ public async getAddons(
+ serverId: string,
+ worldId: string,
+ options?: {
+ from_modpack?: boolean
+ disabled?: boolean
+ addons?: boolean
+ updates?: boolean
+ },
+ ): Promise {
+ const params = new URLSearchParams()
+ if (options?.from_modpack !== undefined)
+ params.set('from_modpack', String(options.from_modpack))
+ if (options?.disabled !== undefined) params.set('disabled', String(options.disabled))
+ if (options?.addons !== undefined) params.set('addons', String(options.addons))
+ if (options?.updates !== undefined) params.set('updates', String(options.updates))
+ const query = params.toString()
+
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/addons${query ? `?${query}` : ''}`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons */
+ public async addAddon(
+ serverId: string,
+ worldId: string,
+ request: Archon.Content.v1.AddAddonRequest,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/delete */
+ public async deleteAddon(
+ serverId: string,
+ worldId: string,
+ request: Archon.Content.v1.RemoveAddonRequest,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/delete`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/disable */
+ public async disableAddon(
+ serverId: string,
+ worldId: string,
+ request: Archon.Content.v1.RemoveAddonRequest,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/disable`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/enable */
+ public async enableAddon(
+ serverId: string,
+ worldId: string,
+ request: Archon.Content.v1.RemoveAddonRequest,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/enable`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/delete-many */
+ public async deleteAddons(
+ serverId: string,
+ worldId: string,
+ items: Archon.Content.v1.RemoveAddonRequest[],
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/delete-many`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: { items },
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/disable-many */
+ public async disableAddons(
+ serverId: string,
+ worldId: string,
+ items: Archon.Content.v1.RemoveAddonRequest[],
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/disable-many`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: { items },
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/enable-many */
+ public async enableAddons(
+ serverId: string,
+ worldId: string,
+ items: Archon.Content.v1.RemoveAddonRequest[],
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/enable-many`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: { items },
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/content */
+ public async installContent(
+ serverId: string,
+ worldId: string,
+ request: Archon.Content.v1.InstallWorldContent,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/content`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/content/repair */
+ public async repair(serverId: string, worldId: string): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/content/repair`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/content/unlink-modpack */
+ public async unlinkModpack(serverId: string, worldId: string): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/content/unlink-modpack`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ },
+ )
+ }
+
+ /** GET /v1/:server_id/worlds/:world_id/addons/update?filename=... */
+ public async getAddonUpdate(
+ serverId: string,
+ worldId: string,
+ filename: string,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/addons/update?filename=${encodeURIComponent(filename)}`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/update */
+ public async updateAddon(
+ serverId: string,
+ worldId: string,
+ request: Archon.Content.v1.UpdateAddonRequest,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/update`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ })
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/update-many */
+ public async updateAddons(
+ serverId: string,
+ worldId: string,
+ addons: Archon.Content.v1.UpdateAddonRequest[],
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/addons/update-many`, {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: { addons },
+ })
+ }
+
+ /** GET /v1/:server_id/worlds/:world_id/content/modpack/update */
+ public async getModpackUpdate(
+ serverId: string,
+ worldId: string,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/content/modpack/update`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/content/modpack/update */
+ public async updateModpack(serverId: string, worldId: string): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/content/modpack/update`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ },
+ )
+ }
+}
diff --git a/packages/api-client/src/modules/archon/index.ts b/packages/api-client/src/modules/archon/index.ts
index 304edc0749..917630f840 100644
--- a/packages/api-client/src/modules/archon/index.ts
+++ b/packages/api-client/src/modules/archon/index.ts
@@ -1,6 +1,6 @@
-export * from './backups/v0'
export * from './backups/v1'
-export * from './content/v0'
+export * from './content/v1'
+export * from './properties/v1'
export * from './servers/v0'
export * from './servers/v1'
export * from './types'
diff --git a/packages/api-client/src/modules/archon/options/v1.ts b/packages/api-client/src/modules/archon/options/v1.ts
new file mode 100644
index 0000000000..00b978c22a
--- /dev/null
+++ b/packages/api-client/src/modules/archon/options/v1.ts
@@ -0,0 +1,37 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Archon } from '../types'
+
+export class ArchonOptionsV1Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'archon_options_v1'
+ }
+
+ /** GET /v1/servers/:server_id/worlds/:world_id/options/startup */
+ public async getStartup(
+ serverId: string,
+ worldId: string,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/options/startup`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** PATCH /v1/servers/:server_id/worlds/:world_id/options/startup */
+ public async patchStartup(
+ serverId: string,
+ worldId: string,
+ body: Archon.Content.v1.PatchRuntimeOptions,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/worlds/${worldId}/options/startup`, {
+ api: 'archon',
+ version: 1,
+ method: 'PATCH',
+ body,
+ })
+ }
+}
diff --git a/packages/api-client/src/modules/archon/properties/v1.ts b/packages/api-client/src/modules/archon/properties/v1.ts
new file mode 100644
index 0000000000..d62fa911fd
--- /dev/null
+++ b/packages/api-client/src/modules/archon/properties/v1.ts
@@ -0,0 +1,40 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Archon } from '../types'
+
+export class ArchonPropertiesV1Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'archon_properties_v1'
+ }
+
+ /** GET /v1/servers/:server_id/worlds/:world_id/properties */
+ public async getProperties(
+ serverId: string,
+ worldId: string,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/properties`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ },
+ )
+ }
+
+ /** PATCH /v1/servers/:server_id/worlds/:world_id/properties */
+ public async patchProperties(
+ serverId: string,
+ worldId: string,
+ body: Archon.Content.v1.PatchPropertiesFields,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/properties`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'PATCH',
+ body,
+ },
+ )
+ }
+}
diff --git a/packages/api-client/src/modules/archon/servers/v0.ts b/packages/api-client/src/modules/archon/servers/v0.ts
index a21301e2b4..14097a8e5c 100644
--- a/packages/api-client/src/modules/archon/servers/v0.ts
+++ b/packages/api-client/src/modules/archon/servers/v0.ts
@@ -1,4 +1,5 @@
import { AbstractModule } from '../../../core/abstract-module'
+import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Archon } from '../types'
export class ArchonServersV0Module extends AbstractModule {
@@ -94,4 +95,210 @@ export class ArchonServersV0Module extends AbstractModule {
body: { action },
})
}
+
+ /**
+ * Reinstall a server with a new loader or modpack
+ * POST /modrinth/v0/servers/:id/reinstall
+ */
+ public async reinstall(
+ serverId: string,
+ request: Archon.Servers.v0.ReinstallRequest,
+ hardReset: boolean = false,
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/reinstall`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ params: { hard: String(hardReset) },
+ body: request,
+ })
+ }
+
+ /**
+ * Get authentication credentials for .mrpack file upload
+ * GET /modrinth/v0/servers/:id/reinstallFromMrpack
+ */
+ public async getReinstallMrpackAuth(
+ serverId: string,
+ ): Promise {
+ return this.client.request(
+ `/servers/${serverId}/reinstallFromMrpack`,
+ {
+ api: 'archon',
+ version: 'modrinth/v0',
+ method: 'GET',
+ },
+ )
+ }
+
+ /**
+ * Reinstall a server from a .mrpack file with progress tracking
+ *
+ * Two-step flow: fetches upload auth, then uploads the .mrpack file to the node.
+ *
+ * @param serverId - Server ID
+ * @param file - .mrpack file to upload
+ * @param hardReset - Whether to erase all server data
+ * @param options - Optional progress callback
+ * @returns Promise resolving to an UploadHandle with progress tracking and cancellation
+ */
+ public async reinstallFromMrpack(
+ serverId: string,
+ file: File,
+ hardReset: boolean = false,
+ options?: {
+ onProgress?: (progress: UploadProgress) => void
+ },
+ ): Promise> {
+ const auth = await this.getReinstallMrpackAuth(serverId)
+
+ const formData = new FormData()
+ formData.append('file', file)
+
+ return this.client.upload('', {
+ api: `https://${auth.url}`,
+ version: 'reinstallMrpackMultiparted',
+ formData,
+ params: { hard: String(hardReset) },
+ headers: { Authorization: `Bearer ${auth.token}` },
+ skipAuth: true,
+ onProgress: options?.onProgress,
+ retry: false,
+ })
+ }
+
+ /**
+ * Update a server's name
+ * POST /modrinth/v0/servers/:id/name
+ */
+ public async updateName(serverId: string, name: string): Promise {
+ await this.client.request(`/servers/${serverId}/name`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ body: { name },
+ })
+ }
+
+ /**
+ * Get allocations for a server
+ * GET /modrinth/v0/servers/:id/allocations
+ */
+ public async getAllocations(serverId: string): Promise {
+ return this.client.request(`/servers/${serverId}/allocations`, {
+ api: 'archon',
+ method: 'GET',
+ version: 'modrinth/v0',
+ })
+ }
+
+ /**
+ * Reserve a new allocation for a server
+ * POST /modrinth/v0/servers/:id/allocations?name=...
+ */
+ public async reserveAllocation(
+ serverId: string,
+ name: string,
+ ): Promise {
+ return this.client.request(`/servers/${serverId}/allocations`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ params: { name },
+ })
+ }
+
+ /**
+ * Update an allocation's name
+ * PUT /modrinth/v0/servers/:id/allocations/:port?name=...
+ */
+ public async updateAllocation(serverId: string, port: number, name: string): Promise {
+ await this.client.request(`/servers/${serverId}/allocations/${port}`, {
+ api: 'archon',
+ method: 'PUT',
+ version: 'modrinth/v0',
+ params: { name },
+ })
+ }
+
+ /**
+ * Delete an allocation
+ * DELETE /modrinth/v0/servers/:id/allocations/:port
+ */
+ public async deleteAllocation(serverId: string, port: number): Promise {
+ await this.client.request(`/servers/${serverId}/allocations/${port}`, {
+ api: 'archon',
+ method: 'DELETE',
+ version: 'modrinth/v0',
+ })
+ }
+
+ /**
+ * Check if a subdomain is available
+ * GET /modrinth/v0/subdomains/:subdomain/isavailable
+ */
+ public async checkSubdomainAvailability(subdomain: string): Promise<{ available: boolean }> {
+ return this.client.request<{ available: boolean }>(`/subdomains/${subdomain}/isavailable`, {
+ api: 'archon',
+ method: 'GET',
+ version: 'modrinth/v0',
+ })
+ }
+
+ /**
+ * Change a server's subdomain
+ * POST /modrinth/v0/servers/:id/subdomain
+ */
+ public async changeSubdomain(serverId: string, subdomain: string): Promise {
+ await this.client.request(`/servers/${serverId}/subdomain`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ body: { subdomain },
+ })
+ }
+
+ /**
+ * Get startup configuration for a server
+ * GET /modrinth/v0/servers/:id/startup
+ */
+ public async getStartupConfig(serverId: string): Promise {
+ return this.client.request(`/servers/${serverId}/startup`, {
+ api: 'archon',
+ method: 'GET',
+ version: 'modrinth/v0',
+ })
+ }
+
+ /**
+ * Update startup configuration for a server
+ * POST /modrinth/v0/servers/:id/startup
+ */
+ public async updateStartupConfig(
+ serverId: string,
+ config: {
+ invocation: string | null
+ jdk_version: string | null
+ jdk_build: string | null
+ },
+ ): Promise {
+ await this.client.request(`/servers/${serverId}/startup`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ body: config,
+ })
+ }
+
+ /**
+ * Dismiss a server notice
+ * POST /modrinth/v0/servers/:id/notices/:noticeId/dismiss
+ */
+ public async dismissNotice(serverId: string, noticeId: number): Promise {
+ await this.client.request(`/servers/${serverId}/notices/${noticeId}/dismiss`, {
+ api: 'archon',
+ method: 'POST',
+ version: 'modrinth/v0',
+ })
+ }
}
diff --git a/packages/api-client/src/modules/archon/servers/v1.ts b/packages/api-client/src/modules/archon/servers/v1.ts
index 06edb14447..5bcb9503b1 100644
--- a/packages/api-client/src/modules/archon/servers/v1.ts
+++ b/packages/api-client/src/modules/archon/servers/v1.ts
@@ -6,6 +6,30 @@ export class ArchonServersV1Module extends AbstractModule {
return 'archon_servers_v1'
}
+ /**
+ * Get list of servers for the authenticated user
+ * GET /v1/servers
+ */
+ public async list(): Promise {
+ return this.client.request('/servers', {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ })
+ }
+
+ /**
+ * Get full server details including worlds, backups, and content
+ * GET /v1/servers/:server_id
+ */
+ public async get(serverId: string): Promise {
+ return this.client.request(`/servers/${serverId}`, {
+ api: 'archon',
+ version: 1,
+ method: 'GET',
+ })
+ }
+
/**
* Get available regions
* GET /v1/regions
@@ -17,4 +41,16 @@ export class ArchonServersV1Module extends AbstractModule {
method: 'GET',
})
}
+
+ /**
+ * End the intro flow for a server
+ * DELETE /v1/servers/:id/flows/intro
+ */
+ public async endIntro(serverId: string): Promise {
+ await this.client.request(`/servers/${serverId}/flows/intro`, {
+ api: 'archon',
+ version: 1,
+ method: 'DELETE',
+ })
+ }
}
diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts
index 24c02b6c21..1ae59c86ed 100644
--- a/packages/api-client/src/modules/archon/types.ts
+++ b/packages/api-client/src/modules/archon/types.ts
@@ -1,37 +1,167 @@
+import type { Labrinth } from '../labrinth/types'
+
export namespace Archon {
export namespace Content {
- export namespace v0 {
- export type ContentKind = 'mod' | 'plugin'
+ export namespace v1 {
+ export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack'
+
+ export type ContentOwnerType = 'user' | 'organization'
+
+ export type ContentOwner = {
+ id: string
+ name: string
+ type: ContentOwnerType
+ icon_url: string | null
+ }
- export type Mod = {
+ export type AddonVersion = {
+ id: string
+ name: string | null
+ environment?: Labrinth.Projects.v3.Environment | null
+ }
+
+ export type Addon = {
+ id: string
filename: string
- project_id: string | undefined
- version_id: string | undefined
- name: string | undefined
- version_number: string | undefined
- icon_url: string | undefined
- owner: string | undefined
+ filesize: number
disabled: boolean
- installing: boolean
+ kind: AddonKind
+ from_modpack: boolean
+ has_update: string | null
+ name: string | null
+ project_id: string | null
+ version: AddonVersion | null
+ owner: ContentOwner | null
+ icon_url: string | null
}
- export type InstallModRequest = {
- rinth_ids: {
- project_id: string
- version_id: string
- }
- install_as: ContentKind
+ export type Addons = {
+ modloader: string | null
+ modloader_version: string | null
+ game_version: string | null
+ modpack: ModpackFields | null
+ addons: Addon[] | null
}
- export type DeleteModRequest = {
- path: string
+ export type AddAddonRequest = {
+ project_id: string
+ version_id?: string
+ kind?: AddonKind
}
- export type UpdateModRequest = {
- replace: string
+ export type RemoveAddonRequest = {
+ kind: AddonKind
+ filename: string
+ }
+
+ export type UpdateAddonRequest = {
+ filename: string
+ version_id?: string | null
+ }
+
+ export type Modloader =
+ | 'forge'
+ | 'neo_forge'
+ | 'fabric'
+ | 'quilt'
+ | 'paper'
+ | 'purpur'
+ | 'vanilla'
+
+ export type ModpackSpec = {
+ platform: 'modrinth'
project_id: string
version_id: string
}
+
+ export type ModpackOwner = {
+ id: string
+ name: string
+ type: 'user' | 'organization'
+ icon_url: string | null
+ }
+
+ export type ModpackFields = {
+ spec: ModpackSpec
+ has_update: string | null
+ title: string | null
+ description: string | null
+ icon_url: string | null
+ owner: ModpackOwner | null
+ version_number: string | null
+ date_published: string | null
+ downloads: number | null
+ followers: number | null
+ }
+
+ export type KnownPropertiesFields = {
+ allow_cheats?: string | null
+ allow_flight?: string | null
+ difficulty?: string | null
+ enforce_whitelist?: string | null
+ force_gamemode?: string | null
+ gamemode?: string | null
+ generate_structures?: string | null
+ generator_settings?: string | null
+ hardcore?: string | null
+ level_seed?: string | null
+ level_type?: string | null
+ max_players?: string | null
+ max_tick_time?: string | null
+ motd?: string | null
+ pause_when_empty_seconds?: string | null
+ player_idle_timeout?: string | null
+ require_resource_pack?: string | null
+ resource_pack?: string | null
+ resource_pack_id?: string | null
+ resource_pack_sha1?: string | null
+ simulation_distance?: string | null
+ spawn_protection?: string | null
+ sync_chunk_writes?: string | null
+ view_distance?: string | null
+ white_list?: string | null
+ }
+
+ export type PropertiesFields = {
+ known: KnownPropertiesFields
+ custom?: Record
+ }
+
+ export type PatchPropertiesFields = {
+ known?: KnownPropertiesFields
+ custom?: Record
+ }
+
+ export type JreVendor = 'temurin' | 'corretto' | 'graal'
+
+ export type RuntimeOptions = {
+ java_version: number | null
+ jre_vendor: JreVendor | null
+ original_invocation: string | null
+ startup_command: string | null
+ }
+
+ export type PatchRuntimeOptions = {
+ java_version?: number | null
+ jre_vendor?: JreVendor | null
+ startup_command?: string | null
+ }
+
+ export type InstallWorldContent =
+ | {
+ content_variant: 'modpack'
+ spec: ModpackSpec
+ soft_override: boolean
+ properties?: PropertiesFields | null
+ }
+ | {
+ content_variant: 'bare'
+ loader: Modloader
+ version: string
+ game_version?: string
+ soft_override: boolean
+ properties?: PropertiesFields | null
+ }
}
}
@@ -148,9 +278,95 @@ export namespace Archon {
url: string // e.g., "node-xyz.modrinth.com/modrinth/v0/fs"
token: string // JWT token for filesystem access
}
+
+ export type ReinstallLoaderRequest = {
+ loader: string
+ loader_version?: string
+ game_version?: string
+ }
+
+ export type ReinstallModpackRequest = {
+ project_id: string
+ version_id?: string
+ }
+
+ export type ReinstallRequest = ReinstallLoaderRequest | ReinstallModpackRequest
+
+ export type MrpackReinstallAuth = {
+ url: string
+ token: string
+ }
+
+ export type Allocation = {
+ port: number
+ name: string
+ }
+
+ export type StartupConfig = {
+ invocation: string
+ original_invocation: string
+ jdk_version: 'lts8' | 'lts11' | 'lts17' | 'lts21'
+ jdk_build: 'corretto' | 'temurin' | 'graal'
+ }
}
export namespace v1 {
+ export type ServerFull = {
+ id: string
+ name: string
+ subdomain: string
+ specs: ServerResources
+ sftp_username: string
+ sftp_password: string
+ tags: string[]
+ location: ServerLocation
+ worlds: WorldFull[]
+ }
+
+ export type ServerResources = {
+ cpu: number
+ memory_mb: number
+ storage_mb: number
+ swap_mb: number
+ }
+
+ export type ServerLocation =
+ | {
+ status: 'assigned'
+ location_metadata: {
+ region: string
+ region_should_be_user_displayed: boolean
+ hostname: string
+ is_decommissioned_node: boolean
+ }
+ }
+ | {
+ status: 'unassigned'
+ }
+
+ export type WorldFull = {
+ id: string
+ name: string
+ created_at: string
+ is_active: boolean
+ backups: Archon.Backups.v1.Backup[]
+ content: WorldContentInfo | null
+ readiness: WorldReadiness
+ }
+
+ export type WorldReadiness = {
+ data_synchronized_fetched: boolean
+ }
+
+ export type WorldContentInfo = {
+ modloader: string
+ modloader_version: string
+ game_version: string
+ java_version: number
+ invocation: string
+ original_invocation: string
+ }
+
export type Region = {
shortcode: string
country_code: string
@@ -174,19 +390,18 @@ export namespace Archon {
export type Backup = {
id: string
+ physical_id: string
name: string
created_at: string
automated: boolean
interrupted: boolean
ongoing: boolean
+ locked: boolean
task?: {
file?: BackupTaskProgress
create?: BackupTaskProgress
restore?: BackupTaskProgress
}
- // TODO: Uncomment when API supports these fields
- // size?: number // bytes
- // creator_id?: string // user ID, or 'auto' for automated backups
}
export type BackupRequest = {
@@ -319,6 +534,37 @@ export namespace Archon {
all: FilesystemOperation[]
}
+ export type ReadinessState =
+ | 'deprovisioned'
+ | 'waiting_active_world'
+ | 'waiting_world_spec_details_for_progress'
+ | 'pulling_world_data'
+ | 'migration_zfs'
+ | 'sync_content'
+ | 'container_readying'
+ | 'ready'
+
+ export type FlattenedPowerState = 'not_ready' | 'starting' | 'running' | 'stopping' | 'idle'
+
+ export type SyncInstallPhase = 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
+
+ export type SyncContentProgress = {
+ started_at: string
+ phase: SyncInstallPhase
+ percent: number
+ }
+
+ export type WSStateEvent = {
+ event: 'state'
+ debug: string
+ power_variant: FlattenedPowerState
+ exit_code?: number | null
+ was_oom?: boolean
+ target: 'start' | 'stop' | 'restart' | null
+ uptime: number
+ progress: SyncContentProgress | null
+ }
+
// Outgoing messages (client -> server)
export type WSOutgoingMessage = WSAuthMessage | WSCommandMessage
@@ -337,6 +583,7 @@ export namespace Archon {
| WSLogEvent
| WSStatsEvent
| WSPowerStateEvent
+ | WSStateEvent
| WSAuthExpiringEvent
| WSAuthIncorrectEvent
| WSAuthOkEvent
diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts
index 884a59b496..d7cfe81457 100644
--- a/packages/api-client/src/modules/index.ts
+++ b/packages/api-client/src/modules/index.ts
@@ -1,13 +1,15 @@
import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module'
-import { ArchonBackupsV0Module } from './archon/backups/v0'
import { ArchonBackupsV1Module } from './archon/backups/v1'
-import { ArchonContentV0Module } from './archon/content/v0'
+import { ArchonContentV1Module } from './archon/content/v1'
+import { ArchonOptionsV1Module } from './archon/options/v1'
+import { ArchonPropertiesV1Module } from './archon/properties/v1'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
+import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
-import { LabrinthVersionsV3Module } from './labrinth'
+import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
@@ -17,6 +19,9 @@ import { LabrinthStateModule } from './labrinth/state'
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
import { LabrinthThreadsV3Module } from './labrinth/threads/v3'
import { LabrinthUsersV2Module } from './labrinth/users/v2'
+import { LauncherMetaManifestV0Module } from './launcher-meta/v0'
+import { PaperVersionsV3Module } from './paper/v3'
+import { PurpurVersionsV2Module } from './purpur/v2'
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
@@ -30,12 +35,15 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
* TODO: Better way? Probably not
*/
export const MODULE_REGISTRY = {
- archon_backups_v0: ArchonBackupsV0Module,
archon_backups_v1: ArchonBackupsV1Module,
- archon_content_v0: ArchonContentV0Module,
+ archon_content_v1: ArchonContentV1Module,
+ archon_options_v1: ArchonOptionsV1Module,
+ archon_properties_v1: ArchonPropertiesV1Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
iso3166_data: ISO3166Module,
+ launchermeta_manifest_v0: LauncherMetaManifestV0Module,
+ kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_collections: LabrinthCollectionsModule,
@@ -46,7 +54,10 @@ export const MODULE_REGISTRY = {
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
labrinth_threads_v3: LabrinthThreadsV3Module,
labrinth_users_v2: LabrinthUsersV2Module,
+ labrinth_versions_v2: LabrinthVersionsV2Module,
labrinth_versions_v3: LabrinthVersionsV3Module,
+ paper_versions_v3: PaperVersionsV3Module,
+ purpur_versions_v2: PurpurVersionsV2Module,
} as const satisfies Record
export type ModuleID = keyof typeof MODULE_REGISTRY
diff --git a/packages/api-client/src/modules/kyros/content/v1.ts b/packages/api-client/src/modules/kyros/content/v1.ts
new file mode 100644
index 0000000000..205c2ff69d
--- /dev/null
+++ b/packages/api-client/src/modules/kyros/content/v1.ts
@@ -0,0 +1,65 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { UploadHandle, UploadProgress } from '../../../types/upload'
+import type { Archon } from '../../archon/types'
+
+export class KyrosContentV1Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'kyros_content_v1'
+ }
+
+ /**
+ * Upload addon files to a world via multipart form data
+ *
+ * @param worldId - World UUID
+ * @param files - Files to upload as addons
+ * @param options - Optional progress callback
+ * @returns UploadHandle with promise, onProgress, and cancel
+ */
+ public uploadAddonFile(
+ worldId: string,
+ files: (File | Blob)[],
+ options?: {
+ onProgress?: (progress: UploadProgress) => void
+ },
+ ): UploadHandle {
+ const formData = new FormData()
+ for (const file of files) {
+ formData.append('file', file, file instanceof File ? file.name : 'file')
+ }
+
+ return this.client.upload(`/worlds/${worldId}/content/upload-addon-file`, {
+ api: '',
+ version: 'v1',
+ formData,
+ onProgress: options?.onProgress,
+ useNodeAuth: true,
+ })
+ }
+
+ /** POST /v1/worlds/:world_id/content/upload-modpack-file */
+ public uploadModpackFile(
+ worldId: string,
+ file: File | Blob,
+ properties: Archon.Content.v1.PropertiesFields,
+ options?: {
+ softOverride?: boolean
+ onProgress?: (progress: UploadProgress) => void
+ },
+ ): UploadHandle {
+ const formData = new FormData()
+ formData.append('file', file, file instanceof File ? file.name : 'file')
+ formData.append('properties', JSON.stringify(properties))
+
+ return this.client.upload(`/worlds/${worldId}/content/upload-modpack-file`, {
+ api: '',
+ version: 'v1',
+ formData,
+ params:
+ options?.softOverride !== undefined
+ ? { soft_override: String(options.softOverride) }
+ : undefined,
+ onProgress: options?.onProgress,
+ useNodeAuth: true,
+ })
+ }
+}
diff --git a/packages/api-client/src/modules/kyros/files/v0.ts b/packages/api-client/src/modules/kyros/files/v0.ts
index 10ed33dfa7..4712c08379 100644
--- a/packages/api-client/src/modules/kyros/files/v0.ts
+++ b/packages/api-client/src/modules/kyros/files/v0.ts
@@ -22,7 +22,7 @@ export class KyrosFilesV0Module extends AbstractModule {
): Promise {
return this.client.request('/fs/list', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'GET',
params: { path, page, page_size: pageSize },
useNodeAuth: true,
@@ -38,7 +38,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async createFileOrFolder(path: string, type: 'file' | 'directory'): Promise {
return this.client.request('/fs/create', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'POST',
params: { path, type },
headers: { 'Content-Type': 'application/octet-stream' },
@@ -55,7 +55,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async downloadFile(path: string): Promise {
return this.client.request('/fs/download', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'GET',
params: { path },
useNodeAuth: true,
@@ -80,7 +80,7 @@ export class KyrosFilesV0Module extends AbstractModule {
): UploadHandle {
return this.client.upload('/fs/create', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
file,
params: { path, type: 'file' },
onProgress: options?.onProgress,
@@ -100,7 +100,7 @@ export class KyrosFilesV0Module extends AbstractModule {
return this.client.request('/fs/update', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'PUT',
params: { path },
body: blob,
@@ -118,7 +118,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async moveFileOrFolder(sourcePath: string, destPath: string): Promise {
return this.client.request('/fs/move', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'POST',
body: { source: sourcePath, destination: destPath },
useNodeAuth: true,
@@ -145,7 +145,7 @@ export class KyrosFilesV0Module extends AbstractModule {
public async deleteFileOrFolder(path: string, recursive: boolean): Promise {
return this.client.request('/fs/delete', {
api: '',
- version: 'v0',
+ version: 'modrinth/v0',
method: 'DELETE',
params: { path, recursive },
useNodeAuth: true,
diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts
index 0de97699fd..0ec5552091 100644
--- a/packages/api-client/src/modules/labrinth/index.ts
+++ b/packages/api-client/src/modules/labrinth/index.ts
@@ -7,4 +7,5 @@ export * from './state'
export * from './tech-review/internal'
export * from './threads/v3'
export * from './users/v2'
+export * from './versions/v2'
export * from './versions/v3'
diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts
index c8018c014a..ec027241e5 100644
--- a/packages/api-client/src/modules/labrinth/types.ts
+++ b/packages/api-client/src/modules/labrinth/types.ts
@@ -617,6 +617,14 @@ export namespace Labrinth {
game_versions: string[]
loaders: string[]
}
+
+ export interface GetProjectVersionsParams {
+ game_versions?: string[]
+ loaders?: string[]
+ include_changelog?: boolean
+ limit?: number
+ offset?: number
+ }
}
// TODO: consolidate duplicated types between v2 and v3 versions
@@ -632,7 +640,8 @@ export namespace Labrinth {
game_versions?: string[]
loaders?: string[]
include_changelog?: boolean
- apiVersion?: 2 | 3
+ limit?: number
+ offset?: number
}
export type VersionChannel = 'release' | 'beta' | 'alpha'
diff --git a/packages/api-client/src/modules/labrinth/versions/v2.ts b/packages/api-client/src/modules/labrinth/versions/v2.ts
new file mode 100644
index 0000000000..8c9df4083d
--- /dev/null
+++ b/packages/api-client/src/modules/labrinth/versions/v2.ts
@@ -0,0 +1,141 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Labrinth } from '../types'
+
+export class LabrinthVersionsV2Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'labrinth_versions_v2'
+ }
+
+ /**
+ * Get versions for a project (v2)
+ *
+ * @param id - Project ID or slug (e.g., 'sodium' or 'AANobbMI')
+ * @param options - Optional query parameters to filter versions
+ * @returns Promise resolving to an array of v2 versions
+ *
+ * @example
+ * ```typescript
+ * const versions = await client.labrinth.versions_v2.getProjectVersions('sodium')
+ * const filteredVersions = await client.labrinth.versions_v2.getProjectVersions('sodium', {
+ * game_versions: ['1.20.1'],
+ * loaders: ['fabric'],
+ * include_changelog: false
+ * })
+ * console.log(versions[0].version_number)
+ * ```
+ */
+ public async getProjectVersions(
+ id: string,
+ options?: Labrinth.Versions.v2.GetProjectVersionsParams,
+ ): Promise {
+ const params: Record = {}
+ if (options?.game_versions?.length) {
+ params.game_versions = JSON.stringify(options.game_versions)
+ }
+ if (options?.loaders?.length) {
+ params.loaders = JSON.stringify(options.loaders)
+ }
+ if (options?.include_changelog === false) {
+ params.include_changelog = 'false'
+ }
+ if (options?.limit != null) {
+ params.limit = String(options.limit)
+ }
+ if (options?.offset != null) {
+ params.offset = String(options.offset)
+ }
+
+ return this.client.request(`/project/${id}/version`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'GET',
+ params: Object.keys(params).length > 0 ? params : undefined,
+ })
+ }
+
+ /**
+ * Get a specific version by ID (v2)
+ *
+ * @param id - Version ID
+ * @returns Promise resolving to the v2 version data
+ *
+ * @example
+ * ```typescript
+ * const version = await client.labrinth.versions_v2.getVersion('DXtmvS8i')
+ * console.log(version.version_number)
+ * ```
+ */
+ public async getVersion(id: string): Promise {
+ return this.client.request(`/version/${id}`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'GET',
+ })
+ }
+
+ /**
+ * Get multiple versions by IDs (v2)
+ *
+ * @param ids - Array of version IDs
+ * @returns Promise resolving to an array of v2 versions
+ *
+ * @example
+ * ```typescript
+ * const versions = await client.labrinth.versions_v2.getVersions(['DXtmvS8i', 'abc123'])
+ * console.log(versions[0].version_number)
+ * ```
+ */
+ public async getVersions(ids: string[]): Promise {
+ return this.client.request(`/versions`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'GET',
+ params: { ids: JSON.stringify(ids) },
+ })
+ }
+
+ /**
+ * Get a version from a project by version ID or number (v2)
+ *
+ * @param projectId - Project ID or slug
+ * @param versionId - Version ID or version number
+ * @returns Promise resolving to the v2 version data
+ *
+ * @example
+ * ```typescript
+ * const version = await client.labrinth.versions_v2.getVersionFromIdOrNumber('sodium', 'DXtmvS8i')
+ * const versionByNumber = await client.labrinth.versions_v2.getVersionFromIdOrNumber('sodium', '0.4.12')
+ * ```
+ */
+ public async getVersionFromIdOrNumber(
+ projectId: string,
+ versionId: string,
+ ): Promise {
+ return this.client.request(
+ `/project/${projectId}/version/${versionId}`,
+ {
+ api: 'labrinth',
+ version: 2,
+ method: 'GET',
+ },
+ )
+ }
+
+ /**
+ * Delete a version by ID (v2)
+ *
+ * @param versionId - Version ID
+ *
+ * @example
+ * ```typescript
+ * await client.labrinth.versions_v2.deleteVersion('DXtmvS8i')
+ * ```
+ */
+ public async deleteVersion(versionId: string): Promise {
+ return this.client.request(`/version/${versionId}`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'DELETE',
+ })
+ }
+}
diff --git a/packages/api-client/src/modules/labrinth/versions/v3.ts b/packages/api-client/src/modules/labrinth/versions/v3.ts
index 1a3a023242..bd7437e9de 100644
--- a/packages/api-client/src/modules/labrinth/versions/v3.ts
+++ b/packages/api-client/src/modules/labrinth/versions/v3.ts
@@ -35,8 +35,14 @@ export class LabrinthVersionsV3Module extends AbstractModule {
if (options?.loaders?.length) {
params.loaders = JSON.stringify(options.loaders)
}
- if (options?.include_changelog !== undefined) {
- params.include_changelog = options.include_changelog
+ if (options?.include_changelog === false) {
+ params.include_changelog = 'false'
+ }
+ if (options?.limit != null) {
+ params.limit = String(options.limit)
+ }
+ if (options?.offset != null) {
+ params.offset = String(options.offset)
}
return this.client.request(`/project/${id}/version`, {
diff --git a/packages/api-client/src/modules/launcher-meta/types.ts b/packages/api-client/src/modules/launcher-meta/types.ts
new file mode 100644
index 0000000000..309bf1eca0
--- /dev/null
+++ b/packages/api-client/src/modules/launcher-meta/types.ts
@@ -0,0 +1,19 @@
+export namespace LauncherMeta {
+ export namespace Manifest {
+ export namespace v0 {
+ export type LoaderVersion = {
+ id: string
+ stable: boolean
+ }
+
+ export type GameVersionEntry = {
+ id: string
+ loaders: LoaderVersion[]
+ }
+
+ export type Manifest = {
+ gameVersions: GameVersionEntry[]
+ }
+ }
+ }
+}
diff --git a/packages/api-client/src/modules/launcher-meta/v0.ts b/packages/api-client/src/modules/launcher-meta/v0.ts
new file mode 100644
index 0000000000..2135051e5d
--- /dev/null
+++ b/packages/api-client/src/modules/launcher-meta/v0.ts
@@ -0,0 +1,23 @@
+import { $fetch } from 'ofetch'
+
+import { AbstractModule } from '../../core/abstract-module'
+import type { LauncherMeta } from './types'
+
+export type { LauncherMeta } from './types'
+
+const BASE_URL = 'https://launcher-meta.modrinth.com'
+
+export class LauncherMetaManifestV0Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'launchermeta_manifest_v0'
+ }
+
+ /**
+ * Get the loader manifest for a given loader platform.
+ *
+ * @param loader - Loader platform (fabric, forge, quilt, neo)
+ */
+ public async getManifest(loader: string): Promise {
+ return $fetch(`${BASE_URL}/${loader}/v0/manifest.json`)
+ }
+}
diff --git a/packages/api-client/src/modules/paper/types.ts b/packages/api-client/src/modules/paper/types.ts
new file mode 100644
index 0000000000..f8febff0c0
--- /dev/null
+++ b/packages/api-client/src/modules/paper/types.ts
@@ -0,0 +1,9 @@
+export namespace Paper {
+ export namespace Versions {
+ export namespace v3 {
+ export type VersionBuilds = {
+ builds: number[]
+ }
+ }
+ }
+}
diff --git a/packages/api-client/src/modules/paper/v3.ts b/packages/api-client/src/modules/paper/v3.ts
new file mode 100644
index 0000000000..83ea2fcad2
--- /dev/null
+++ b/packages/api-client/src/modules/paper/v3.ts
@@ -0,0 +1,25 @@
+import { $fetch } from 'ofetch'
+
+import { AbstractModule } from '../../core/abstract-module'
+import type { Paper } from './types'
+
+export type { Paper } from './types'
+
+const BASE_URL = 'https://fill.papermc.io/v3'
+
+export class PaperVersionsV3Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'paper_versions_v3'
+ }
+
+ /**
+ * Get available Paper builds for a Minecraft version.
+ *
+ * @param mcVersion - Minecraft version (e.g. "1.21.4")
+ */
+ public async getBuilds(mcVersion: string): Promise {
+ return $fetch(
+ `${BASE_URL}/projects/paper/versions/${mcVersion}`,
+ )
+ }
+}
diff --git a/packages/api-client/src/modules/purpur/types.ts b/packages/api-client/src/modules/purpur/types.ts
new file mode 100644
index 0000000000..998b818095
--- /dev/null
+++ b/packages/api-client/src/modules/purpur/types.ts
@@ -0,0 +1,11 @@
+export namespace Purpur {
+ export namespace Versions {
+ export namespace v2 {
+ export type VersionBuilds = {
+ builds: {
+ all: string[]
+ }
+ }
+ }
+ }
+}
diff --git a/packages/api-client/src/modules/purpur/v2.ts b/packages/api-client/src/modules/purpur/v2.ts
new file mode 100644
index 0000000000..441be34e02
--- /dev/null
+++ b/packages/api-client/src/modules/purpur/v2.ts
@@ -0,0 +1,23 @@
+import { $fetch } from 'ofetch'
+
+import { AbstractModule } from '../../core/abstract-module'
+import type { Purpur } from './types'
+
+export type { Purpur } from './types'
+
+const BASE_URL = 'https://api.purpurmc.org/v2'
+
+export class PurpurVersionsV2Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'purpur_versions_v2'
+ }
+
+ /**
+ * Get available Purpur builds for a Minecraft version.
+ *
+ * @param mcVersion - Minecraft version (e.g. "1.21.4")
+ */
+ public async getBuilds(mcVersion: string): Promise {
+ return $fetch(`${BASE_URL}/purpur/${mcVersion}`)
+ }
+}
diff --git a/packages/api-client/src/modules/types.ts b/packages/api-client/src/modules/types.ts
index f0aff3b3c7..3baa5cf661 100644
--- a/packages/api-client/src/modules/types.ts
+++ b/packages/api-client/src/modules/types.ts
@@ -2,3 +2,6 @@ export * from './archon/types'
export * from './iso3166/types'
export * from './kyros/types'
export * from './labrinth/types'
+export * from './launcher-meta/types'
+export * from './paper/types'
+export * from './purpur/types'
diff --git a/packages/api-client/src/platform/nuxt.ts b/packages/api-client/src/platform/nuxt.ts
index f3a9079ba6..566471ab61 100644
--- a/packages/api-client/src/platform/nuxt.ts
+++ b/packages/api-client/src/platform/nuxt.ts
@@ -13,27 +13,32 @@ import { XHRUploadClient } from './xhr-upload-client'
*
* This provides cross-request persistence in SSR while also working in client-side.
* State is shared between requests in the same Nuxt context.
+ *
+ * Note: useState must be called during initialization (in setup context) and cached,
+ * as it won't work during async operations when the Nuxt context may be lost.
*/
export class NuxtCircuitBreakerStorage implements CircuitBreakerStorage {
- private getState(): Map {
+ private state: Map
+
+ constructor() {
// @ts-expect-error - useState is provided by Nuxt runtime
- const state = useState>(
+ const stateRef = useState>(
'circuit-breaker-state',
() => new Map(),
)
- return state.value
+ this.state = stateRef.value
}
get(key: string): CircuitBreakerState | undefined {
- return this.getState().get(key)
+ return this.state.get(key)
}
set(key: string, state: CircuitBreakerState): void {
- this.getState().set(key, state)
+ this.state.set(key, state)
}
clear(key: string): void {
- this.getState().delete(key)
+ this.state.delete(key)
}
}
diff --git a/packages/api-client/src/platform/tauri.ts b/packages/api-client/src/platform/tauri.ts
index a91d80dfc5..357a73ab7e 100644
--- a/packages/api-client/src/platform/tauri.ts
+++ b/packages/api-client/src/platform/tauri.ts
@@ -69,8 +69,16 @@ export class TauriModrinthClient extends XHRUploadClient {
let fullUrl = url
if (options.params) {
- const queryParams = new URLSearchParams(options.params as Record).toString()
- fullUrl = `${url}?${queryParams}`
+ const filteredParams: Record = {}
+ for (const [key, value] of Object.entries(options.params)) {
+ if (value !== undefined && value !== null) {
+ filteredParams[key] = String(value)
+ }
+ }
+ const queryString = new URLSearchParams(filteredParams).toString()
+ if (queryString) {
+ fullUrl = `${url}?${queryString}`
+ }
}
const response = await tauriFetch(fullUrl, {
diff --git a/packages/api-client/src/platform/websocket-generic.ts b/packages/api-client/src/platform/websocket-generic.ts
index 135007706e..00cbca570e 100644
--- a/packages/api-client/src/platform/websocket-generic.ts
+++ b/packages/api-client/src/platform/websocket-generic.ts
@@ -57,14 +57,30 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
ws.onclose = (event) => {
+ console.debug(`[WebSocket] Closed for server ${serverId}:`, {
+ code: event.code,
+ reason: event.reason,
+ wasClean: event.wasClean,
+ })
if (event.code !== NORMAL_CLOSURE) {
this.scheduleReconnect(serverId, auth)
}
}
- ws.onerror = (error) => {
- console.error(`[WebSocket] Error for server ${serverId}:`, error)
- reject(new Error(`WebSocket connection failed for server ${serverId}`))
+ ws.onerror = (event) => {
+ const url = ws.url
+ const readyState = ws.readyState
+ console.error(`[WebSocket] Error for server ${serverId}:`, {
+ url,
+ readyState,
+ readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState],
+ type: (event as Event).type,
+ })
+ reject(
+ new Error(
+ `WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
+ ),
+ )
}
} catch (error) {
reject(error)
diff --git a/packages/app-lib/.sqlx/query-4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec.json b/packages/app-lib/.sqlx/query-4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec.json
new file mode 100644
index 0000000000..1d65ebe354
--- /dev/null
+++ b/packages/app-lib/.sqlx/query-4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "SQLite",
+ "query": "\n SELECT data as \"data?: sqlx::types::Json\"\n FROM cache\n WHERE data_type = $1 AND id = $2\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "data?: sqlx::types::Json",
+ "ordinal": 0,
+ "type_info": "Null"
+ }
+ ],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": [
+ true
+ ]
+ },
+ "hash": "4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec"
+}
diff --git a/packages/app-lib/src/api/cache.rs b/packages/app-lib/src/api/cache.rs
index 123b5d429c..62dc68a24d 100644
--- a/packages/app-lib/src/api/cache.rs
+++ b/packages/app-lib/src/api/cache.rs
@@ -53,3 +53,20 @@ pub async fn purge_cache_types(
Ok(())
}
+
+/// Get versions for a project (without changelogs for fast loading).
+/// Uses the cache system with the ProjectVersions cache type.
+#[tracing::instrument]
+pub async fn get_project_versions(
+ project_id: &str,
+ cache_behaviour: Option,
+) -> crate::Result>> {
+ let state = crate::State::get().await?;
+ CachedEntry::get_project_versions(
+ project_id,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await
+}
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index efa9b20752..5cafca7249 100644
--- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs
@@ -18,12 +18,13 @@ pub mod worlds;
pub mod data {
pub use crate::state::{
- CacheBehaviour, CacheValueType, Credentials, Dependency, DirectoryInfo,
- Hooks, JavaVersion, LinkedData, MemorySettings, ModLoader,
- ModrinthCredentials, Organization, ProcessMetadata, ProfileFile,
- Project, ProjectType, ProjectV3, SearchResult, SearchResults,
- SearchResultsV3, Settings, TeamMember, Theme, User, UserFriend,
- Version, WindowSize,
+ CacheBehaviour, CacheValueType, ContentItem, ContentItemOwner,
+ ContentItemProject, ContentItemVersion, Credentials, Dependency,
+ DirectoryInfo, Hooks, JavaVersion, LinkedData, LinkedModpackInfo,
+ MemorySettings, ModLoader, ModrinthCredentials, Organization,
+ OwnerType, ProcessMetadata, ProfileFile, Project, ProjectType,
+ ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings,
+ TeamMember, Theme, User, UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
}
diff --git a/packages/app-lib/src/api/pack/install_from.rs b/packages/app-lib/src/api/pack/install_from.rs
index 6f68af259e..29f940d6cc 100644
--- a/packages/app-lib/src/api/pack/install_from.rs
+++ b/packages/app-lib/src/api/pack/install_from.rs
@@ -1,4 +1,5 @@
use crate::State;
+use crate::api::profile;
use crate::data::ModLoader;
use crate::event::emit::{emit_loading, init_loading};
use crate::event::{LoadingBarId, LoadingBarType};
@@ -226,6 +227,24 @@ pub async fn generate_pack_from_version_id(
})?;
emit_loading(&loading_bar, 10.0, None)?;
+ // Update profile with correct loader and game version from the API version metadata,
+ // so the UI shows accurate info while the pack file is still downloading.
+ if let Some(game_version) = version.game_versions.first() {
+ let loader = version
+ .loaders
+ .first()
+ .map(|l| ModLoader::from_string(l))
+ .unwrap_or(ModLoader::Vanilla);
+ let game_version = game_version.clone();
+ let profile_path_clone = profile_path.clone();
+ profile::edit(&profile_path_clone, |prof| {
+ prof.game_version.clone_from(&game_version);
+ prof.loader = loader;
+ async { Ok(()) }
+ })
+ .await?;
+ }
+
let (url, hash) =
if let Some(file) = version.files.iter().find(|x| x.primary) {
Some((file.url.clone(), file.hashes.get("sha1")))
@@ -303,6 +322,12 @@ pub async fn generate_pack_from_version_id(
None
};
+ // Set the icon immediately so the UI shows it during download.
+ if let Some(ref icon_path) = icon {
+ let _ =
+ profile::edit_icon(&profile_path, Some(icon_path.as_path())).await;
+ }
+
Ok(CreatePack {
file,
description: CreatePackDescription {
diff --git a/packages/app-lib/src/api/pack/install_mrpack.rs b/packages/app-lib/src/api/pack/install_mrpack.rs
index 0fdead311c..91b14b3644 100644
--- a/packages/app-lib/src/api/pack/install_mrpack.rs
+++ b/packages/app-lib/src/api/pack/install_mrpack.rs
@@ -8,7 +8,7 @@ use crate::pack::install_from::{
use crate::state::{
CacheBehaviour, CachedEntry, ProfileInstallStage, SideType, cache_file_hash,
};
-use crate::util::fetch::{fetch_mirrors, write};
+use crate::util::fetch::{fetch_mirrors, sha1_async, write};
use crate::util::io;
use crate::{State, profile};
use async_zip::base::read::seek::ZipFileReader;
@@ -115,6 +115,53 @@ pub async fn install_zipped_mrpack_files(
.into());
}
+ // Cache the modpack file hashes for later filtering of user-added content
+ // Includes both manifest file hashes and computed hashes for override files
+ if let Some(ref version_id) = version_id {
+ let mut file_hashes: Vec = pack
+ .files
+ .iter()
+ .filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
+ .collect();
+
+ // Also hash files from overrides folders (these aren't in modrinth.index.json)
+ let override_entries: Vec = zip_reader
+ .file()
+ .entries()
+ .iter()
+ .enumerate()
+ .filter_map(|(index, entry)| {
+ let filename = entry.filename().as_str().ok()?;
+ let is_override = (filename.starts_with("overrides/")
+ || filename.starts_with("client-overrides/")
+ || filename.starts_with("server-overrides/"))
+ && !filename.ends_with('/');
+ is_override.then_some(index)
+ })
+ .collect();
+
+ for index in override_entries {
+ let mut file_bytes = Vec::new();
+ let mut entry_reader = zip_reader.reader_with_entry(index).await?;
+ entry_reader.read_to_end_checked(&mut file_bytes).await?;
+
+ let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?;
+ file_hashes.push(hash);
+ }
+
+ tracing::info!(
+ "Caching {} modpack file hashes for version {}",
+ file_hashes.len(),
+ version_id
+ );
+ CachedEntry::cache_modpack_files(version_id, file_hashes, &state.pool)
+ .await?;
+ } else {
+ tracing::warn!(
+ "No version_id available, skipping modpack file hash caching"
+ );
+ }
+
// Sets generated profile attributes to the pack ones (using profile::edit)
set_profile_information(
profile_path.clone(),
diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs
index e6111bac91..0384d3c652 100644
--- a/packages/app-lib/src/api/profile/mod.rs
+++ b/packages/app-lib/src/api/profile/mod.rs
@@ -8,8 +8,9 @@ use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
};
use crate::state::{
- CacheBehaviour, CachedEntry, Credentials, JavaVersion, ProcessMetadata,
- ProfileFile, ProfileInstallStage, ProjectType, SideType,
+ CacheBehaviour, CachedEntry, ContentItem, Credentials, Dependency,
+ JavaVersion, LinkedModpackInfo, ProcessMetadata, ProfileFile,
+ ProfileInstallStage, ProjectType, SideType,
};
use crate::event::{ProfilePayloadType, emit::emit_profile};
@@ -93,6 +94,119 @@ pub async fn get_projects(
}
}
+#[tracing::instrument]
+pub async fn get_installed_project_ids(
+ path: &str,
+) -> crate::Result> {
+ let state = State::get().await?;
+
+ if let Some(profile) = get(path).await? {
+ let ids = profile
+ .get_installed_project_ids(&state.pool, &state.api_semaphore)
+ .await?;
+ Ok(ids)
+ } else {
+ Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
+ .as_error())
+ }
+}
+
+/// Get content items with rich metadata for a profile
+///
+/// Returns content items filtered to exclude modpack files (if linked),
+/// sorted alphabetically by project name.
+#[tracing::instrument]
+pub async fn get_content_items(
+ path: &str,
+ cache_behaviour: Option,
+) -> crate::Result> {
+ let state = State::get().await?;
+
+ if let Some(profile) = get(path).await? {
+ let items = crate::state::get_content_items(
+ &profile,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await?;
+ Ok(items)
+ } else {
+ Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
+ .as_error())
+ }
+}
+
+/// Get content items that are part of the linked modpack
+///
+/// Returns the modpack's dependencies as ContentItem list.
+/// Returns empty vec if the profile is not linked to a modpack.
+#[tracing::instrument]
+pub async fn get_linked_modpack_content(
+ path: &str,
+ cache_behaviour: Option,
+) -> crate::Result> {
+ let state = State::get().await?;
+
+ if let Some(profile) = get(path).await? {
+ let items = crate::state::get_linked_modpack_content(
+ &profile,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await?;
+ Ok(items)
+ } else {
+ Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
+ .as_error())
+ }
+}
+
+/// Convert a list of dependencies into ContentItems with rich metadata
+#[tracing::instrument]
+pub async fn get_dependencies_as_content_items(
+ dependencies: Vec,
+ cache_behaviour: Option,
+) -> crate::Result> {
+ let state = State::get().await?;
+
+ let items = crate::state::dependencies_to_content_items(
+ &dependencies,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await?;
+ Ok(items)
+}
+
+/// Get linked modpack info for a profile
+///
+/// Returns project, version, and owner information for the linked modpack,
+/// or None if the profile is not linked to a modpack.
+#[tracing::instrument]
+pub async fn get_linked_modpack_info(
+ path: &str,
+ cache_behaviour: Option,
+) -> crate::Result> {
+ let state = State::get().await?;
+
+ if let Some(profile) = get(path).await? {
+ let info = crate::state::get_linked_modpack_info(
+ &profile,
+ cache_behaviour,
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await?;
+ Ok(info)
+ } else {
+ Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
+ .as_error())
+ }
+}
+
/// Get profile's full path in the filesystem
#[tracing::instrument]
pub async fn get_full_path(path: &str) -> crate::Result {
diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs
index 803b0a01e0..1346b6d887 100644
--- a/packages/app-lib/src/state/cache.rs
+++ b/packages/app-lib/src/state/cache.rs
@@ -36,6 +36,9 @@ pub enum CacheValueType {
FileUpdate,
SearchResults,
SearchResultsV3,
+ ModpackFiles,
+ /// Cached list of versions for a project (without changelogs for fast loading)
+ ProjectVersions,
}
impl CacheValueType {
@@ -59,6 +62,8 @@ impl CacheValueType {
CacheValueType::FileUpdate => "file_update",
CacheValueType::SearchResults => "search_results",
CacheValueType::SearchResultsV3 => "search_results_v3",
+ CacheValueType::ModpackFiles => "modpack_files",
+ CacheValueType::ProjectVersions => "project_versions",
}
}
@@ -82,6 +87,8 @@ impl CacheValueType {
"file_update" => CacheValueType::FileUpdate,
"search_results" => CacheValueType::SearchResults,
"search_results_v3" => CacheValueType::SearchResultsV3,
+ "modpack_files" => CacheValueType::ModpackFiles,
+ "project_versions" => CacheValueType::ProjectVersions,
_ => CacheValueType::Project,
}
}
@@ -91,7 +98,10 @@ impl CacheValueType {
match self {
CacheValueType::File => 30 * 24 * 60 * 60, // 30 days
CacheValueType::FileHash => 30 * 24 * 60 * 60, // 30 days
- _ => 30 * 60, // 30 minutes
+ // ModpackFiles never expire - version_id is immutable so hashes never change
+ // TODO: There has to be a way to exclude this from the "Purge cache" stuff?
+ CacheValueType::ModpackFiles => 100 * 365 * 24 * 60 * 60, // 100 years (effectively never)
+ _ => 30 * 60, // 30 minutes
}
}
@@ -126,11 +136,27 @@ impl CacheValueType {
| CacheValueType::LoaderManifest
| CacheValueType::FileUpdate
| CacheValueType::SearchResults
- | CacheValueType::SearchResultsV3 => None,
+ | CacheValueType::SearchResultsV3
+ | CacheValueType::ModpackFiles
+ | CacheValueType::ProjectVersions => None,
}
}
}
+/// Cached modpack file hashes for filtering content
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct CachedModpackFiles {
+ pub version_id: String,
+ pub file_hashes: Vec,
+}
+
+/// Cached list of versions for a project (without changelogs for fast loading)
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct CachedProjectVersions {
+ pub project_id: String,
+ pub versions: Vec,
+}
+
// De/serialization strategy:
// - on serialize:
// - in the `cache` table, save the `data_type` (variant of this value) alongside
@@ -165,6 +191,8 @@ pub enum CacheValue {
FileUpdate(CachedFileUpdate),
SearchResults(SearchResults),
SearchResultsV3(SearchResultsV3),
+ ModpackFiles(CachedModpackFiles),
+ ProjectVersions(CachedProjectVersions),
ProjectV3(ProjectV3),
}
@@ -349,7 +377,8 @@ pub struct Version {
pub name: String,
pub version_number: String,
- pub changelog: String,
+ #[serde(default)]
+ pub changelog: Option,
pub changelog_url: Option,
pub date_published: DateTime,
@@ -499,6 +528,8 @@ impl CacheValue {
CacheValue::FileUpdate(_) => CacheValueType::FileUpdate,
CacheValue::SearchResults(_) => CacheValueType::SearchResults,
CacheValue::SearchResultsV3(_) => CacheValueType::SearchResultsV3,
+ CacheValue::ModpackFiles(_) => CacheValueType::ModpackFiles,
+ CacheValue::ProjectVersions(_) => CacheValueType::ProjectVersions,
}
}
@@ -541,6 +572,8 @@ impl CacheValue {
}
CacheValue::SearchResults(search) => search.search.clone(),
CacheValue::SearchResultsV3(search) => search.search.clone(),
+ CacheValue::ModpackFiles(files) => files.version_id.clone(),
+ CacheValue::ProjectVersions(pv) => pv.project_id.clone(),
}
}
@@ -567,7 +600,9 @@ impl CacheValue {
| CacheValue::LoaderManifest { .. }
| CacheValue::FileUpdate(_)
| CacheValue::SearchResults(_)
- | CacheValue::SearchResultsV3(_) => None,
+ | CacheValue::SearchResultsV3(_)
+ | CacheValue::ModpackFiles(_)
+ | CacheValue::ProjectVersions(_) => None,
}
}
@@ -601,6 +636,8 @@ impl CacheValue {
CacheValue::FileUpdate(update) => serde_json::to_value(update),
CacheValue::SearchResults(search) => serde_json::to_value(search),
CacheValue::SearchResultsV3(search) => serde_json::to_value(search),
+ CacheValue::ModpackFiles(files) => serde_json::to_value(files),
+ CacheValue::ProjectVersions(pv) => serde_json::to_value(pv),
}
.map_err(|err| {
crate::ErrorKind::OtherError(format!(
@@ -1515,6 +1552,56 @@ impl CachedEntry {
})
.collect()
}
+ CacheValueType::ModpackFiles => {
+ // ModpackFiles are only stored locally during modpack installation,
+ // not fetched from an external API
+ vec![]
+ }
+ CacheValueType::ProjectVersions => {
+ let mut values = vec![];
+
+ for key in keys {
+ let project_id = key.to_string();
+ let url = format!(
+ "{}project/{}/version?include_changelog=false",
+ env!("MODRINTH_API_URL"),
+ project_id
+ );
+
+ match fetch_json::>(
+ Method::GET,
+ &url,
+ None,
+ None,
+ fetch_semaphore,
+ pool,
+ )
+ .await
+ {
+ Ok(versions) => {
+ values.push((
+ CacheValue::ProjectVersions(
+ CachedProjectVersions {
+ project_id,
+ versions,
+ },
+ )
+ .get_entry(),
+ true,
+ ));
+ }
+ Err(e) => {
+ tracing::warn!(
+ "Failed to fetch versions for project {}: {:?}",
+ project_id,
+ e
+ );
+ }
+ }
+ }
+
+ values
+ }
CacheValueType::SearchResultsV3 => {
let fetch_urls = keys
.iter()
@@ -1628,6 +1715,12 @@ impl CachedEntry {
CacheValueType::SearchResultsV3 => CacheValue::SearchResultsV3(
parse(data, id, "search_results_v3")?,
),
+ CacheValueType::ModpackFiles => {
+ CacheValue::ModpackFiles(parse(data, id, "modpack_files")?)
+ }
+ CacheValueType::ProjectVersions => CacheValue::ProjectVersions(
+ parse(data, id, "project_versions")?,
+ ),
};
Ok(value)
@@ -1700,6 +1793,83 @@ impl CachedEntry {
Ok(())
}
+
+ /// Store modpack file hashes in cache
+ pub async fn cache_modpack_files(
+ version_id: &str,
+ file_hashes: Vec,
+ pool: &SqlitePool,
+ ) -> crate::Result<()> {
+ let data = CachedModpackFiles {
+ version_id: version_id.to_string(),
+ file_hashes,
+ };
+
+ let entry = CachedEntry {
+ id: version_id.to_string(),
+ alias: None,
+ expires: Utc::now().timestamp()
+ + CacheValueType::ModpackFiles.expiry(),
+ type_: CacheValueType::ModpackFiles,
+ data: Some(CacheValue::ModpackFiles(data)),
+ };
+
+ Self::upsert_many(&[entry], pool).await
+ }
+
+ /// Get modpack file hashes from cache
+ pub async fn get_modpack_files(
+ version_id: &str,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+ ) -> crate::Result> {
+ let entry = Self::get(
+ CacheValueType::ModpackFiles,
+ version_id,
+ None,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ if let Some(CachedEntry {
+ data: Some(CacheValue::ModpackFiles(files)),
+ ..
+ }) = entry
+ {
+ return Ok(Some(files));
+ }
+
+ Ok(None)
+ }
+
+ /// Get versions for a project (without changelogs for fast loading)
+ #[tracing::instrument(skip(pool, fetch_semaphore))]
+ pub async fn get_project_versions(
+ project_id: &str,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+ ) -> crate::Result>> {
+ let entry = Self::get(
+ CacheValueType::ProjectVersions,
+ project_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ if let Some(CachedEntry {
+ data: Some(CacheValue::ProjectVersions(pv)),
+ ..
+ }) = entry
+ {
+ return Ok(Some(pv.versions));
+ }
+
+ Ok(None)
+ }
}
pub async fn cache_file_hash(
diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs
new file mode 100644
index 0000000000..aa82c73656
--- /dev/null
+++ b/packages/app-lib/src/state/instances/content.rs
@@ -0,0 +1,887 @@
+//! # Content API
+//!
+//! ## Data Flow
+//!
+//! 1. Frontend calls `get_content_items(profile_path)`
+//! 2. Backend fetches all installed files via `Profile::get_projects()`
+//! 3. If profile is linked to a modpack:
+//! - Fetch modpack file hashes from cache (populated during installation)
+//! - Fallback: re-download .mrpack if cache miss (cleared/expired)
+//! - Filter out files that belong to the modpack
+//! 4. For remaining files, fetch project/version/owner metadata in parallel
+//! 5. Return sorted `ContentItem` list
+//!
+//! ## Caching
+//!
+//! Modpack file hashes are cached in `CacheValueType::ModpackFiles`
+//! during modpack installation. The cache never expires (version_id is
+//! immutable), so re-download is only needed if cache was cleared or
+//! profile predates this caching mechanism.
+
+use crate::pack::install_from::{PackFileHash, PackFormat};
+use crate::state::profiles::{Profile, ProfileFile, ProjectType};
+use crate::state::{CacheBehaviour, CachedEntry};
+use crate::util::fetch::{FetchSemaphore, fetch_mirrors, sha1_async};
+use async_zip::base::read::seek::ZipFileReader;
+use serde::{Deserialize, Serialize};
+use sqlx::SqlitePool;
+use std::collections::HashSet;
+use std::io::Cursor;
+
+/// Content item with rich metadata for frontend display
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ContentItem {
+ /// Unique identifier (the file name)
+ pub file_name: String,
+ /// Relative path to the file within the profile
+ pub file_path: String,
+ /// SHA1 hash of the file
+ pub hash: String,
+ /// File size in bytes
+ pub size: u64,
+ /// Whether the file is enabled (not .disabled)
+ pub enabled: bool,
+ /// Type of project (mod, resourcepack, etc.)
+ pub project_type: ProjectType,
+ /// Modrinth project info if recognized
+ pub project: Option,
+ /// Version info if recognized
+ pub version: Option,
+ /// Owner info (organization or user)
+ pub owner: Option,
+ /// Whether an update is available
+ pub has_update: bool,
+ /// The recommended version ID to update to (if has_update is true)
+ pub update_version_id: Option,
+ /// When the file was added to the instance (file modification time)
+ pub date_added: Option,
+}
+
+/// Project information for content item display
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ContentItemProject {
+ pub id: String,
+ pub slug: Option,
+ pub title: String,
+ pub icon_url: Option,
+}
+
+/// Version information for content item display
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ContentItemVersion {
+ pub id: String,
+ pub version_number: String,
+ pub file_name: String,
+ pub date_published: Option,
+}
+
+/// Owner information for content item display
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ContentItemOwner {
+ pub id: String,
+ pub name: String,
+ pub avatar_url: Option,
+ #[serde(rename = "type")]
+ pub owner_type: OwnerType,
+}
+
+/// Type of content owner
+#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum OwnerType {
+ User,
+ Organization,
+}
+
+use crate::state::cache::{Dependency, Organization, TeamMember};
+use crate::state::{Project, Version};
+
+/// Full linked modpack information including owner and update status
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct LinkedModpackInfo {
+ pub project: Project,
+ pub version: Version,
+ pub owner: Option,
+ /// Whether an update is available for this modpack
+ pub has_update: bool,
+ /// The version ID to update to (if has_update is true)
+ pub update_version_id: Option,
+ /// The full version info for the update (if has_update is true)
+ pub update_version: Option,
+}
+
+/// Get linked modpack info including project, version, owner, and update status.
+/// Returns None if the profile is not linked to a modpack.
+pub async fn get_linked_modpack_info(
+ profile: &Profile,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let Some(linked_data) = &profile.linked_data else {
+ return Ok(None);
+ };
+
+ // Vanilla server projects have linked_data with an empty version_id
+ if linked_data.version_id.is_empty() {
+ return Ok(None);
+ }
+
+ // Fetch project, version, and all project versions in parallel
+ let (project, version, all_versions) = tokio::try_join!(
+ CachedEntry::get_project(
+ &linked_data.project_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ ),
+ CachedEntry::get_version(
+ &linked_data.version_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ ),
+ CachedEntry::get_project_versions(
+ &linked_data.project_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ ),
+ )?;
+
+ let version = version.ok_or_else(|| {
+ crate::ErrorKind::InputError(format!(
+ "Linked modpack version {} not found",
+ linked_data.version_id
+ ))
+ })?;
+
+ // For server instances, linked_data.project_id is the server project,
+ // but the version may belong to a different (modpack) project.
+ // If so, fetch the actual modpack project for display and update checking.
+ let (project, all_versions) =
+ if version.project_id != linked_data.project_id {
+ let (modpack_project, modpack_versions) = tokio::try_join!(
+ CachedEntry::get_project(
+ &version.project_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ ),
+ CachedEntry::get_project_versions(
+ &version.project_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ ),
+ )?;
+ (modpack_project.or(project), modpack_versions)
+ } else {
+ (project, all_versions)
+ };
+
+ let project = project.ok_or_else(|| {
+ crate::ErrorKind::InputError(format!(
+ "Linked modpack project {} not found",
+ linked_data.project_id
+ ))
+ })?;
+
+ // Resolve owner - prefer organization, fall back to team owner
+ let owner = if let Some(org_id) = &project.organization {
+ let org = CachedEntry::get_organization(
+ org_id,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+ org.map(|o| ContentItemOwner {
+ id: o.id,
+ name: o.name,
+ avatar_url: o.icon_url,
+ owner_type: OwnerType::Organization,
+ })
+ } else {
+ let team = CachedEntry::get_team(
+ &project.team,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+ team.and_then(|t| {
+ t.into_iter()
+ .find(|m| m.is_owner)
+ .map(|m| ContentItemOwner {
+ id: m.user.id,
+ name: m.user.username,
+ avatar_url: m.user.avatar_url,
+ owner_type: OwnerType::User,
+ })
+ })
+ };
+
+ // Check for updates
+ let (has_update, update_version_id, update_version) = check_modpack_update(
+ profile,
+ &linked_data.version_id,
+ &version,
+ all_versions,
+ );
+
+ Ok(Some(LinkedModpackInfo {
+ project,
+ version,
+ owner,
+ has_update,
+ update_version_id,
+ update_version,
+ }))
+}
+
+/// Check if a newer compatible version exists for the linked modpack.
+/// Returns (has_update, update_version_id, update_version).
+fn check_modpack_update(
+ profile: &Profile,
+ installed_version_id: &str,
+ installed_version: &Version,
+ all_versions: Option>,
+) -> (bool, Option, Option) {
+ let Some(versions) = all_versions else {
+ return (false, None, None);
+ };
+
+ // Get the loader as a string for comparison
+ let loader_str = profile.loader.as_str().to_lowercase();
+ let game_version = &profile.game_version;
+
+ // Filter to compatible versions
+ let mut compatible_versions: Vec<&Version> = versions
+ .iter()
+ .filter(|v| {
+ // Must support the profile's game version
+ let supports_game = v.game_versions.contains(game_version);
+
+ // Must support the profile's loader
+ // The v2 API replaces "mrpack" with actual loaders from mrpack_loaders,
+ // but if mrpack_loaders is missing, loaders may be just ["mrpack"].
+ // In that case we can't filter by loader, so accept the version.
+ let real_loaders: Vec<_> = v
+ .loaders
+ .iter()
+ .filter(|l| l.to_lowercase() != "mrpack")
+ .collect();
+ let supports_loader = real_loaders.is_empty()
+ || real_loaders.iter().any(|l| l.to_lowercase() == loader_str);
+
+ supports_game && supports_loader
+ })
+ .collect();
+
+ // Sort by date_published descending (newest first)
+ compatible_versions.sort_by(|a, b| b.date_published.cmp(&a.date_published));
+
+ // Find the newest compatible version
+ if let Some(newest) = compatible_versions.first() {
+ // Check if the newest version is different and newer than installed
+ if newest.id != installed_version_id
+ && newest.date_published > installed_version.date_published
+ {
+ return (true, Some(newest.id.clone()), Some((*newest).clone()));
+ }
+ }
+
+ (false, None, None)
+}
+
+/// Get content items with rich metadata, filtered to exclude modpack content.
+/// Returns only user-added content (not part of the linked modpack).
+pub async fn get_content_items(
+ profile: &Profile,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let all_files = profile
+ .get_projects(cache_behaviour, pool, fetch_semaphore)
+ .await?;
+
+ let modpack_hashes: HashSet = if let Some(ref linked_data) =
+ profile.linked_data
+ {
+ if linked_data.version_id.is_empty() {
+ HashSet::new()
+ } else {
+ tracing::info!(
+ "Fetching modpack file hashes for version_id={}, project_id={}",
+ linked_data.version_id,
+ linked_data.project_id
+ );
+ match get_modpack_file_hashes(
+ &linked_data.version_id,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ {
+ Ok(hashes) => {
+ tracing::info!(
+ "Got {} modpack file hashes for version {}",
+ hashes.len(),
+ linked_data.version_id
+ );
+ hashes
+ }
+ Err(e) => {
+ tracing::error!(
+ "Failed to fetch modpack file hashes for version {}: {}",
+ linked_data.version_id,
+ e
+ );
+ HashSet::new()
+ }
+ }
+ }
+ } else {
+ HashSet::new()
+ };
+
+ let user_files: Vec<(String, ProfileFile)> = all_files
+ .into_iter()
+ .filter(|(_, file)| !modpack_hashes.contains(&file.hash))
+ .collect();
+
+ profile_files_to_content_items(
+ &profile.path,
+ &user_files,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+}
+
+/// Pre-fetched metadata for projects, versions, teams, and organizations.
+struct ResolvedMetadata {
+ projects: Vec,
+ versions: Vec,
+ teams: Vec>,
+ organizations: Vec,
+}
+
+/// Fetch project, version, team, and organization metadata in parallel batches.
+async fn resolve_metadata(
+ project_ids: &HashSet,
+ version_ids: &HashSet,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result {
+ let project_ids_vec: Vec<&str> =
+ project_ids.iter().map(|s| s.as_str()).collect();
+ let version_ids_vec: Vec<&str> =
+ version_ids.iter().map(|s| s.as_str()).collect();
+
+ let (projects, versions) =
+ if !project_ids.is_empty() || !version_ids.is_empty() {
+ tokio::try_join!(
+ async {
+ if project_ids.is_empty() {
+ Ok(Vec::new())
+ } else {
+ CachedEntry::get_project_many(
+ &project_ids_vec,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ }
+ },
+ async {
+ if version_ids.is_empty() {
+ Ok(Vec::new())
+ } else {
+ CachedEntry::get_version_many(
+ &version_ids_vec,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ }
+ }
+ )?
+ } else {
+ (Vec::new(), Vec::new())
+ };
+
+ let team_ids: HashSet =
+ projects.iter().map(|p| p.team.clone()).collect();
+ let org_ids: HashSet = projects
+ .iter()
+ .filter_map(|p| p.organization.clone())
+ .collect();
+
+ let team_ids_vec: Vec<&str> = team_ids.iter().map(|s| s.as_str()).collect();
+ let org_ids_vec: Vec<&str> = org_ids.iter().map(|s| s.as_str()).collect();
+
+ let (teams, organizations) = if !team_ids.is_empty() || !org_ids.is_empty()
+ {
+ tokio::try_join!(
+ async {
+ if team_ids.is_empty() {
+ Ok(Vec::new())
+ } else {
+ CachedEntry::get_team_many(
+ &team_ids_vec,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ }
+ },
+ async {
+ if org_ids.is_empty() {
+ Ok(Vec::new())
+ } else {
+ CachedEntry::get_organization_many(
+ &org_ids_vec,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ }
+ }
+ )?
+ } else {
+ (Vec::new(), Vec::new())
+ };
+
+ Ok(ResolvedMetadata {
+ projects,
+ versions,
+ teams,
+ organizations,
+ })
+}
+
+/// Shared helper: convert profile files to ContentItems with rich metadata.
+/// Used by both `get_content_items` (user-added files) and
+/// `get_linked_modpack_content` (modpack-bundled files).
+async fn profile_files_to_content_items(
+ profile_path: &str,
+ files: &[(String, ProfileFile)],
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let project_ids: HashSet = files
+ .iter()
+ .filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.project_id.clone()))
+ .collect();
+
+ let version_ids: HashSet = files
+ .iter()
+ .filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.version_id.clone()))
+ .collect();
+
+ let meta = resolve_metadata(
+ &project_ids,
+ &version_ids,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ let profile_base_path =
+ crate::api::profile::get_full_path(profile_path).await?;
+
+ // Batch-read file modification times off the main async runtime
+ let paths: Vec = files
+ .iter()
+ .map(|(path, _)| profile_base_path.join(path))
+ .collect();
+
+ let modification_times: Vec> =
+ tokio::task::spawn_blocking(move || {
+ paths
+ .iter()
+ .map(|path| {
+ std::fs::metadata(path).and_then(|m| m.modified()).ok().map(
+ |t| {
+ chrono::DateTime::::from(t)
+ .to_rfc3339()
+ },
+ )
+ })
+ .collect()
+ })
+ .await?;
+
+ let mut items: Vec = files
+ .iter()
+ .enumerate()
+ .map(|(i, (path, file))| {
+ let project = file.metadata.as_ref().and_then(|m| {
+ meta.projects.iter().find(|p| p.id == m.project_id)
+ });
+
+ let version = file.metadata.as_ref().and_then(|m| {
+ meta.versions.iter().find(|v| v.id == m.version_id)
+ });
+
+ let owner = project.and_then(|p| {
+ resolve_owner(p, &meta.teams, &meta.organizations)
+ });
+
+ ContentItem {
+ file_name: file.file_name.clone(),
+ file_path: path.clone(),
+ hash: file.hash.clone(),
+ size: file.size,
+ enabled: !file.file_name.ends_with(".disabled"),
+ project_type: file.project_type,
+ project: project.map(|p| ContentItemProject {
+ id: p.id.clone(),
+ slug: p.slug.clone(),
+ title: p.title.clone(),
+ icon_url: p.icon_url.clone(),
+ }),
+ version: version.map(|v| ContentItemVersion {
+ id: v.id.clone(),
+ version_number: v.version_number.clone(),
+ file_name: file.file_name.clone(),
+ date_published: Some(v.date_published.to_rfc3339()),
+ }),
+ owner,
+ has_update: file.update_version_id.is_some(),
+ update_version_id: file.update_version_id.clone(),
+ date_added: modification_times[i].clone(),
+ }
+ })
+ .collect();
+
+ items.sort_by(|a, b| {
+ let name_a = a
+ .project
+ .as_ref()
+ .map(|p| p.title.as_str())
+ .unwrap_or(&a.file_name);
+ let name_b = b
+ .project
+ .as_ref()
+ .map(|p| p.title.as_str())
+ .unwrap_or(&b.file_name);
+ name_a.to_lowercase().cmp(&name_b.to_lowercase())
+ });
+
+ Ok(items)
+}
+
+/// Resolve the owner of a project from pre-fetched teams and organizations.
+fn resolve_owner(
+ project: &Project,
+ teams: &[Vec],
+ organizations: &[Organization],
+) -> Option {
+ if let Some(org_id) = &project.organization {
+ organizations.iter().find(|o| &o.id == org_id).map(|o| {
+ ContentItemOwner {
+ id: o.id.clone(),
+ name: o.name.clone(),
+ avatar_url: o.icon_url.clone(),
+ owner_type: OwnerType::Organization,
+ }
+ })
+ } else {
+ teams
+ .iter()
+ .find(|t| t.first().is_some_and(|m| m.team_id == project.team))
+ .and_then(|t| t.iter().find(|m| m.is_owner))
+ .map(|m| ContentItemOwner {
+ id: m.user.id.clone(),
+ name: m.user.username.clone(),
+ avatar_url: m.user.avatar_url.clone(),
+ owner_type: OwnerType::User,
+ })
+ }
+}
+
+/// Get content items that are part of the linked modpack (not user-added).
+/// Returns modpack-bundled files with full on-disk metadata (file_path, enabled, etc).
+/// Returns empty vec if the profile is not linked to a modpack.
+pub async fn get_linked_modpack_content(
+ profile: &Profile,
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let Some(linked_data) = &profile.linked_data else {
+ return Ok(Vec::new());
+ };
+
+ let all_files = profile
+ .get_projects(cache_behaviour, pool, fetch_semaphore)
+ .await?;
+
+ let modpack_hashes: HashSet = match get_modpack_file_hashes(
+ &linked_data.version_id,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+ {
+ Ok(hashes) => hashes,
+ Err(e) => {
+ tracing::warn!("Failed to fetch modpack file hashes: {}", e);
+ return Ok(Vec::new());
+ }
+ };
+
+ // Inverse of get_content_items: keep only modpack-bundled files
+ let modpack_files: Vec<(String, ProfileFile)> = all_files
+ .into_iter()
+ .filter(|(_, file)| modpack_hashes.contains(&file.hash))
+ .collect();
+
+ profile_files_to_content_items(
+ &profile.path,
+ &modpack_files,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await
+}
+
+/// Convert a list of dependencies into ContentItems with rich metadata.
+/// Fetches project, version, and owner info for each dependency.
+pub async fn dependencies_to_content_items(
+ dependencies: &[Dependency],
+ cache_behaviour: Option,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ let project_ids: HashSet = dependencies
+ .iter()
+ .filter_map(|d| d.project_id.clone())
+ .collect();
+
+ if project_ids.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let version_ids: HashSet = dependencies
+ .iter()
+ .filter_map(|d| d.version_id.clone())
+ .collect();
+
+ let meta = resolve_metadata(
+ &project_ids,
+ &version_ids,
+ cache_behaviour,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ let mut items: Vec = dependencies
+ .iter()
+ .filter_map(|dep| {
+ let project_id = dep.project_id.as_ref()?;
+ let project = meta.projects.iter().find(|p| &p.id == project_id)?;
+
+ let version = dep
+ .version_id
+ .as_ref()
+ .and_then(|vid| meta.versions.iter().find(|v| &v.id == vid));
+
+ let owner =
+ resolve_owner(project, &meta.teams, &meta.organizations);
+
+ let project_type = match project.project_type.as_str() {
+ "mod" => ProjectType::Mod,
+ "resourcepack" => ProjectType::ResourcePack,
+ "shader" => ProjectType::ShaderPack,
+ "datapack" => ProjectType::DataPack,
+ _ => ProjectType::Mod,
+ };
+
+ Some(ContentItem {
+ file_name: version
+ .and_then(|v| v.files.first())
+ .map(|f| f.filename.clone())
+ .unwrap_or_else(|| {
+ format!(
+ "{}.jar",
+ project.slug.as_deref().unwrap_or(&project.id)
+ )
+ }),
+ file_path: String::new(),
+ hash: String::new(),
+ size: version
+ .and_then(|v| v.files.first())
+ .map(|f| f.size as u64)
+ .unwrap_or(0),
+ enabled: true,
+ project_type,
+ project: Some(ContentItemProject {
+ id: project.id.clone(),
+ slug: project.slug.clone(),
+ title: project.title.clone(),
+ icon_url: project.icon_url.clone(),
+ }),
+ version: version.map(|v| ContentItemVersion {
+ id: v.id.clone(),
+ version_number: v.version_number.clone(),
+ file_name: v
+ .files
+ .first()
+ .map(|f| f.filename.clone())
+ .unwrap_or_default(),
+ date_published: Some(v.date_published.to_rfc3339()),
+ }),
+ owner,
+ has_update: false,
+ update_version_id: None,
+ date_added: None,
+ })
+ })
+ .collect();
+
+ items.sort_by(|a, b| {
+ let name_a = a
+ .project
+ .as_ref()
+ .map(|p| p.title.as_str())
+ .unwrap_or(&a.file_name);
+ let name_b = b
+ .project
+ .as_ref()
+ .map(|p| p.title.as_str())
+ .unwrap_or(&b.file_name);
+ name_a.to_lowercase().cmp(&name_b.to_lowercase())
+ });
+
+ Ok(items)
+}
+
+/// Gets SHA1 hashes of all files in a modpack version.
+/// Checks cache first, falls back to downloading mrpack if not cached.
+async fn get_modpack_file_hashes(
+ version_id: &str,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+) -> crate::Result> {
+ if let Some(cached) =
+ CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
+ .await?
+ {
+ tracing::info!(
+ "Cache hit: {} modpack file hashes for version {}",
+ cached.file_hashes.len(),
+ version_id
+ );
+ return Ok(cached.file_hashes.into_iter().collect());
+ }
+
+ tracing::warn!(
+ "Cache miss: modpack files not cached, downloading mrpack for version {}",
+ version_id
+ );
+
+ let version =
+ CachedEntry::get_version(version_id, None, pool, fetch_semaphore)
+ .await?
+ .ok_or_else(|| {
+ crate::ErrorKind::InputError(format!(
+ "Modpack version {version_id} not found"
+ ))
+ })?;
+
+ let primary_file = version
+ .files
+ .iter()
+ .find(|f| f.primary)
+ .or_else(|| version.files.first())
+ .ok_or_else(|| {
+ crate::ErrorKind::InputError(format!(
+ "No files found for modpack version {version_id}"
+ ))
+ })?;
+
+ let mrpack_bytes = fetch_mirrors(
+ &[&primary_file.url],
+ primary_file.hashes.get("sha1").map(|s| s.as_str()),
+ fetch_semaphore,
+ pool,
+ )
+ .await?;
+
+ let reader = Cursor::new(&mrpack_bytes);
+ let mut zip_reader =
+ ZipFileReader::with_tokio(reader).await.map_err(|_| {
+ crate::ErrorKind::InputError(
+ "Failed to read modpack zip".to_string(),
+ )
+ })?;
+
+ let manifest_idx = zip_reader
+ .file()
+ .entries()
+ .iter()
+ .position(|f| {
+ matches!(f.filename().as_str(), Ok("modrinth.index.json"))
+ })
+ .ok_or_else(|| {
+ crate::ErrorKind::InputError(
+ "No modrinth.index.json found in mrpack".to_string(),
+ )
+ })?;
+
+ let mut manifest = String::new();
+ let mut entry_reader = zip_reader.reader_with_entry(manifest_idx).await?;
+ entry_reader.read_to_string_checked(&mut manifest).await?;
+
+ let pack: PackFormat = serde_json::from_str(&manifest)?;
+
+ let mut hashes: Vec = pack
+ .files
+ .iter()
+ .filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
+ .collect();
+
+ // Also hash files from overrides folders (these aren't in modrinth.index.json)
+ let override_entries: Vec = zip_reader
+ .file()
+ .entries()
+ .iter()
+ .enumerate()
+ .filter_map(|(index, entry)| {
+ let filename = entry.filename().as_str().ok()?;
+ let is_override = (filename.starts_with("overrides/")
+ || filename.starts_with("client-overrides/")
+ || filename.starts_with("server-overrides/"))
+ && !filename.ends_with('/');
+ is_override.then_some(index)
+ })
+ .collect();
+
+ for index in override_entries {
+ let mut file_bytes = Vec::new();
+ let mut entry_reader = zip_reader.reader_with_entry(index).await?;
+ entry_reader.read_to_end_checked(&mut file_bytes).await?;
+
+ let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?;
+ hashes.push(hash);
+ }
+
+ CachedEntry::cache_modpack_files(version_id, hashes.clone(), pool).await?;
+
+ Ok(hashes.into_iter().collect())
+}
diff --git a/packages/app-lib/src/state/instances/mod.rs b/packages/app-lib/src/state/instances/mod.rs
new file mode 100644
index 0000000000..931e32a6c1
--- /dev/null
+++ b/packages/app-lib/src/state/instances/mod.rs
@@ -0,0 +1,4 @@
+//! Instance-related modules for profile/instance management.
+
+mod content;
+pub use self::content::*;
diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs
index df043de35c..a5b9fdd136 100644
--- a/packages/app-lib/src/state/legacy_converter.rs
+++ b/packages/app-lib/src/state/legacy_converter.rs
@@ -622,7 +622,7 @@ impl From for Version {
featured: value.featured,
name: value.name,
version_number: value.version_number,
- changelog: value.changelog,
+ changelog: Some(value.changelog),
changelog_url: value.changelog_url,
date_published: value.date_published,
downloads: value.downloads,
diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs
index 6c3a69126b..b44306f92e 100644
--- a/packages/app-lib/src/state/mod.rs
+++ b/packages/app-lib/src/state/mod.rs
@@ -13,6 +13,9 @@ pub use self::dirs::*;
mod profiles;
pub use self::profiles::*;
+mod instances;
+pub use self::instances::*;
+
mod settings;
pub use self::settings::*;
diff --git a/packages/app-lib/src/state/profiles.rs b/packages/app-lib/src/state/profiles.rs
index 6669ee129a..5978ba2108 100644
--- a/packages/app-lib/src/state/profiles.rs
+++ b/packages/app-lib/src/state/profiles.rs
@@ -2,7 +2,7 @@ use super::settings::{Hooks, MemorySettings, WindowSize};
use crate::profile::get_full_path;
use crate::state::server_join_log::JoinLogEntry;
use crate::state::{
- CacheBehaviour, CachedEntry, CachedFileHash, cache_file_hash,
+ CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, cache_file_hash,
};
use crate::util;
use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon};
@@ -409,6 +409,14 @@ macro_rules! select_profiles_with_predicate {
};
}
+struct InitialScanFile {
+ path: String,
+ file_name: String,
+ project_type: ProjectType,
+ size: u64,
+ cache_key: String,
+}
+
impl Profile {
pub async fn get(
path: &str,
@@ -640,6 +648,8 @@ impl Profile {
&& let Some(file_name) = subdirectory
.file_name()
.and_then(|x| x.to_str())
+ && !(project_type == ProjectType::ShaderPack
+ && file_name.ends_with(".txt"))
{
let file_size = subdirectory
.metadata()
@@ -909,63 +919,8 @@ impl Profile {
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result> {
- let path = crate::api::profile::get_full_path(&self.path).await?;
-
- struct InitialScanFile {
- path: String,
- file_name: String,
- project_type: ProjectType,
- size: u64,
- cache_key: String,
- }
-
- let mut keys = vec![];
-
- for project_type in ProjectType::iterator() {
- let folder = project_type.get_folder();
- let path = path.join(folder);
-
- if path.exists() {
- for subdirectory in std::fs::read_dir(&path)
- .map_err(|e| io::IOError::with_path(e, &path))?
- {
- let subdirectory =
- subdirectory.map_err(io::IOError::from)?.path();
- if subdirectory.is_file()
- && let Some(file_name) =
- subdirectory.file_name().and_then(|x| x.to_str())
- {
- let file_size = subdirectory
- .metadata()
- .map_err(io::IOError::from)?
- .len();
-
- keys.push(InitialScanFile {
- path: format!(
- "{}/{folder}/{}",
- self.path,
- file_name.trim_end_matches(".disabled")
- ),
- file_name: file_name.to_string(),
- project_type,
- size: file_size,
- cache_key: format!(
- "{file_size}-{}/{folder}/{file_name}",
- self.path
- ),
- });
- }
- }
- }
- }
-
- let file_hashes = CachedEntry::get_file_hash_many(
- &keys.iter().map(|s| &*s.cache_key).collect::>(),
- None,
- pool,
- fetch_semaphore,
- )
- .await?;
+ let (keys, file_hashes) =
+ self.scan_and_hash(pool, fetch_semaphore).await?;
let file_updates = file_hashes
.iter()
@@ -976,7 +931,7 @@ impl Profile {
file_hashes.iter().map(|x| &*x.hash).collect::>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::>();
- let (mut file_info, file_updates) = tokio::try_join!(
+ let (file_info, file_updates) = tokio::try_join!(
CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
@@ -991,18 +946,23 @@ impl Profile {
)
)?;
+ let mut keys_by_path: std::collections::HashMap<
+ String,
+ InitialScanFile,
+ > = keys.into_iter().map(|k| (k.path.clone(), k)).collect();
+
+ let mut file_info_by_hash: std::collections::HashMap<
+ String,
+ CachedFile,
+ > = file_info.into_iter().map(|f| (f.hash.clone(), f)).collect();
+
let files = DashMap::new();
for hash in file_hashes {
- let info_index = file_info.iter().position(|x| x.hash == hash.hash);
- let file = info_index.map(|x| file_info.remove(x));
-
- if let Some(initial_file_index) = keys
- .iter()
- .position(|x| x.path == hash.path.trim_end_matches(".disabled"))
- {
- let initial_file = keys.remove(initial_file_index);
+ let file = file_info_by_hash.remove(&hash.hash);
+ let trimmed = hash.path.trim_end_matches(".disabled");
+ if let Some(initial_file) = keys_by_path.remove(trimmed) {
let path = format!(
"{}/{}",
initial_file.project_type.get_folder(),
@@ -1043,6 +1003,95 @@ impl Profile {
Ok(files)
}
+ pub async fn get_installed_project_ids(
+ &self,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+ ) -> crate::Result> {
+ let (_keys, file_hashes) =
+ self.scan_and_hash(pool, fetch_semaphore).await?;
+
+ let file_hashes_ref =
+ file_hashes.iter().map(|x| &*x.hash).collect::>();
+
+ let file_info = CachedEntry::get_file_many(
+ &file_hashes_ref,
+ None,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ let project_ids: Vec = file_info
+ .into_iter()
+ .map(|f| f.project_id)
+ .collect::>()
+ .into_iter()
+ .collect();
+
+ Ok(project_ids)
+ }
+
+ async fn scan_and_hash(
+ &self,
+ pool: &SqlitePool,
+ fetch_semaphore: &FetchSemaphore,
+ ) -> crate::Result<(Vec, Vec)> {
+ let path = crate::api::profile::get_full_path(&self.path).await?;
+
+ let mut keys = vec![];
+
+ for project_type in ProjectType::iterator() {
+ let folder = project_type.get_folder();
+ let path = path.join(folder);
+
+ if path.exists() {
+ for subdirectory in std::fs::read_dir(&path)
+ .map_err(|e| io::IOError::with_path(e, &path))?
+ {
+ let subdirectory =
+ subdirectory.map_err(io::IOError::from)?.path();
+ if subdirectory.is_file()
+ && let Some(file_name) =
+ subdirectory.file_name().and_then(|x| x.to_str())
+ && !(project_type == ProjectType::ShaderPack
+ && file_name.ends_with(".txt"))
+ {
+ let file_size = subdirectory
+ .metadata()
+ .map_err(io::IOError::from)?
+ .len();
+
+ keys.push(InitialScanFile {
+ path: format!(
+ "{}/{folder}/{}",
+ self.path,
+ file_name.trim_end_matches(".disabled")
+ ),
+ file_name: file_name.to_string(),
+ project_type,
+ size: file_size,
+ cache_key: format!(
+ "{file_size}-{}/{folder}/{file_name}",
+ self.path
+ ),
+ });
+ }
+ }
+ }
+ }
+
+ let file_hashes = CachedEntry::get_file_hash_many(
+ &keys.iter().map(|s| &*s.cache_key).collect::>(),
+ None,
+ pool,
+ fetch_semaphore,
+ )
+ .await?;
+
+ Ok((keys, file_hashes))
+ }
+
fn get_cache_key(file: &CachedFileHash, profile: &Profile) -> String {
format!(
"{}-{}-{}",
diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts
index ea1d254f20..6a7f30daf4 100644
--- a/packages/assets/generated-icons.ts
+++ b/packages/assets/generated-icons.ts
@@ -13,6 +13,7 @@ import _ArrowDownLeftIcon from './icons/arrow-down-left.svg?component'
import _ArrowLeftIcon from './icons/arrow-left.svg?component'
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
import _ArrowUpIcon from './icons/arrow-up.svg?component'
+import _ArrowUpDownIcon from './icons/arrow-up-down.svg?component'
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
import _AsteriskIcon from './icons/asterisk.svg?component'
import _BadgeCheckIcon from './icons/badge-check.svg?component'
@@ -45,6 +46,7 @@ import _ChevronDownIcon from './icons/chevron-down.svg?component'
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
import _ChevronRightIcon from './icons/chevron-right.svg?component'
import _ChevronUpIcon from './icons/chevron-up.svg?component'
+import _CircleAlertIcon from './icons/circle-alert.svg?component'
import _CircleUserIcon from './icons/circle-user.svg?component'
import _ClearIcon from './icons/clear.svg?component'
import _ClientIcon from './icons/client.svg?component'
@@ -165,6 +167,7 @@ import _PackageOpenIcon from './icons/package-open.svg?component'
import _PackagePlusIcon from './icons/package-plus.svg?component'
import _PaintbrushIcon from './icons/paintbrush.svg?component'
import _PaletteIcon from './icons/palette.svg?component'
+import _PencilIcon from './icons/pencil.svg?component'
import _PickaxeIcon from './icons/pickaxe.svg?component'
import _PlayIcon from './icons/play.svg?component'
import _PlugIcon from './icons/plug.svg?component'
@@ -346,6 +349,7 @@ import _TagLoaderVelocityIcon from './icons/tags/loaders/velocity.svg?component'
import _TagLoaderWaterfallIcon from './icons/tags/loaders/waterfall.svg?component'
import _TerminalSquareIcon from './icons/terminal-square.svg?component'
import _TestIcon from './icons/test.svg?component'
+import _TextCursorInputIcon from './icons/text-cursor-input.svg?component'
import _TextQuoteIcon from './icons/text-quote.svg?component'
import _TimerIcon from './icons/timer.svg?component'
import _ToggleLeftIcon from './icons/toggle-left.svg?component'
@@ -390,6 +394,7 @@ export const ArrowDownLeftIcon = _ArrowDownLeftIcon
export const ArrowLeftIcon = _ArrowLeftIcon
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
export const ArrowUpIcon = _ArrowUpIcon
+export const ArrowUpDownIcon = _ArrowUpDownIcon
export const ArrowUpRightIcon = _ArrowUpRightIcon
export const AsteriskIcon = _AsteriskIcon
export const BadgeCheckIcon = _BadgeCheckIcon
@@ -422,6 +427,7 @@ export const ChevronDownIcon = _ChevronDownIcon
export const ChevronLeftIcon = _ChevronLeftIcon
export const ChevronRightIcon = _ChevronRightIcon
export const ChevronUpIcon = _ChevronUpIcon
+export const CircleAlertIcon = _CircleAlertIcon
export const CircleUserIcon = _CircleUserIcon
export const ClearIcon = _ClearIcon
export const ClientIcon = _ClientIcon
@@ -542,6 +548,7 @@ export const PackageOpenIcon = _PackageOpenIcon
export const PackagePlusIcon = _PackagePlusIcon
export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon
+export const PencilIcon = _PencilIcon
export const PickaxeIcon = _PickaxeIcon
export const PlayIcon = _PlayIcon
export const PlugIcon = _PlugIcon
@@ -723,6 +730,7 @@ export const TagLoaderVelocityIcon = _TagLoaderVelocityIcon
export const TagLoaderWaterfallIcon = _TagLoaderWaterfallIcon
export const TerminalSquareIcon = _TerminalSquareIcon
export const TestIcon = _TestIcon
+export const TextCursorInputIcon = _TextCursorInputIcon
export const TextQuoteIcon = _TextQuoteIcon
export const TimerIcon = _TimerIcon
export const ToggleLeftIcon = _ToggleLeftIcon
diff --git a/packages/assets/icons/arrow-up-down.svg b/packages/assets/icons/arrow-up-down.svg
new file mode 100644
index 0000000000..0607f68e0b
--- /dev/null
+++ b/packages/assets/icons/arrow-up-down.svg
@@ -0,0 +1 @@
+