diff --git a/Cargo.lock b/Cargo.lock index 56812adc34..ed57e8ed6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5268,6 +5268,7 @@ dependencies = [ "labrinth", "lettre", "meilisearch-sdk", + "modrinth-content-management", "modrinth-util", "muralpay", "murmur2", @@ -5835,6 +5836,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "modrinth-content-management" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "serde", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "modrinth-log" version = "0.0.0" @@ -10562,9 +10574,9 @@ name = "theseus" version = "1.0.0-local" dependencies = [ "ariadne", - "async-compression", "async-minecraft-ping", "async-recursion", + "async-trait", "async-tungstenite", "async-walkdir", "async_zip", @@ -10594,6 +10606,7 @@ dependencies = [ "hickory-resolver 0.25.2", "indicatif", "itertools 0.14.0", + "modrinth-content-management", "notify", "notify-debouncer-mini", "p256", diff --git a/Cargo.toml b/Cargo.toml index 29e6d78cc3..5b20d32326 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "packages/ariadne", "packages/daedalus", "packages/labrinth-derive", + "packages/modrinth-content-management", "packages/modrinth-log", "packages/modrinth-maxmind", "packages/modrinth-util", @@ -32,7 +33,6 @@ actix-ws = "0.3.0" arc-swap = "1.7.1" argon2 = { version = "0.5.3", features = ["std"] } ariadne = { path = "packages/ariadne" } -async-compression = { version = "0.4.32", default-features = false } async-minecraft-ping = { path = "packages/async-minecraft-ping" } async-recursion = "1.1.1" async-stripe = { version = "0.41.0", default-features = false, features = [ @@ -120,6 +120,7 @@ lettre = { version = "0.11.19", default-features = false, features = [ maxminddb = "0.26.0" meilisearch-sdk = { version = "0.30.0", default-features = false } modrinth-log = { path = "packages/modrinth-log" } +modrinth-content-management = { path = "packages/modrinth-content-management" } modrinth-util = { path = "packages/modrinth-util" } muralpay = { path = "packages/muralpay" } murmur2 = "0.1.0" diff --git a/apps/app-frontend/src/composables/browse/use-app-server-browse.ts b/apps/app-frontend/src/composables/browse/use-app-server-browse.ts index 7e78a2545a..f26e89fa52 100644 --- a/apps/app-frontend/src/composables/browse/use-app-server-browse.ts +++ b/apps/app-frontend/src/composables/browse/use-app-server-browse.ts @@ -11,8 +11,7 @@ import { process_listener } from '@/helpers/events' import { kill, list as listInstances } from '@/helpers/instance' import { get_by_instance_id } from '@/helpers/process' import type { GameInstance } from '@/helpers/types' -import { add_server_to_instance, getServerLatency } from '@/helpers/worlds' -import { getServerAddress } from '@/store/install.js' +import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds' interface BrowseServerInstance { name: string diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts index be330d0fc4..cb4694fbcc 100644 --- a/apps/app-frontend/src/helpers/instance.ts +++ b/apps/app-frontend/src/helpers/instance.ts @@ -182,6 +182,35 @@ export async function update_project(instanceId: string, projectPath: string): P // Returns a path to the new project file export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update' +export interface ResolutionPreferences { + game_versions?: string[] + loaders?: string[] +} + +export interface ResolveContentRequest { + project_id: string + version_id?: string | null + content_type: Labrinth.Content.v3.ContentType + selected?: ResolutionPreferences +} + +export interface ResolvedContent { + project_id: string + version_id: string + dependent_on_version_id?: string | null +} + +export interface ResolveContentPlan { + primary: ResolvedContent + dependencies: ResolvedContent[] + skipped: Array<{ + project_id: string + version_id?: string | null + dependent_on_version_id?: string | null + reason: string + }> +} + export async function add_project_from_version( instanceId: string, versionId: string, @@ -196,6 +225,28 @@ export async function add_project_from_version( }) } +export async function install_project_with_dependencies( + instanceId: string, + request: ResolveContentRequest, +): Promise { + return await invoke('plugin:instance|instance_install_project_with_dependencies', { + instanceId, + request, + }) +} + +export async function switch_project_version_with_dependencies( + instanceId: string, + projectPath: string, + versionId: string, +): Promise { + return await invoke('plugin:instance|instance_switch_project_version_with_dependencies', { + instanceId, + projectPath, + versionId, + }) +} + // Add a project to an instance from a path + project_type // Returns a path to the new project file export async function add_project_from_path( diff --git a/apps/app-frontend/src/helpers/worlds.ts b/apps/app-frontend/src/helpers/worlds.ts index a5a579d6a3..1ebb00a436 100644 --- a/apps/app-frontend/src/helpers/worlds.ts +++ b/apps/app-frontend/src/helpers/worlds.ts @@ -356,6 +356,28 @@ export function resolveManagedServerWorld( ) } +export function getServerAddress(javaServer?: { address?: string | null } | null) { + if (!javaServer) return null + return javaServer.address ?? null +} + +export async function ensureManagedServerWorldExists( + instanceId: string, + serverName: string, + serverAddress: string | null, +) { + if (!instanceId || !serverAddress) return + try { + const worlds = await get_instance_worlds(instanceId) + const managedWorld = resolveManagedServerWorld(worlds, serverName, serverAddress) + if (!managedWorld) { + await add_server_to_instance(instanceId, serverName, serverAddress, 'prompt') + } + } catch (err) { + console.error('Failed to ensure managed server world exists:', err) + } +} + export async function getServerLatency( address: string, protocolVersion: ProtocolVersion | null = null, diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index 5b275a287b..032808e028 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -109,23 +109,20 @@ import { } from '@/helpers/events.js' import { add_project_from_path, - add_project_from_version, duplicate, edit, - get, get_linked_modpack_content, list, remove_project, + switch_project_version_with_dependencies, toggle_disable_project, update_all, update_managed_modrinth_version, - update_project, } from '@/helpers/instance' import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content' import type { CacheBehaviour, GameInstance } from '@/helpers/types' import { highlightModInInstance } from '@/helpers/utils.js' import { injectContentInstall } from '@/providers/content-install' -import { installVersionDependencies } from '@/store/install' const messages = defineMessages({ shareTitle: { @@ -170,7 +167,8 @@ let savedModalState: ModpackContentModalState | null = null const { formatMessage } = useVIntl() const { handleError, addNotification } = injectNotificationManager() -const { installingItems } = injectContentInstall() +const { installingItems, installRevisionByInstance, installFailureRevisionByInstance } = + injectContentInstall() const router = useRouter() const queryClient = useQueryClient() const debug = useDebugLogger('Mods:ContentUpdate') @@ -186,6 +184,7 @@ const loading = ref(true) const projects = ref([]) const installingBuffer = ref([]) +const handledInstallRevision = ref(0) watch( () => installingItems.value.get(props.instance.id), @@ -209,11 +208,25 @@ const mergedProjects = computed(() => { const active = installingItems.value.get(props.instance.id) const pending = active ?? installingBuffer.value if (pending.length === 0) return projects.value - const realProjectIds = new Set(projects.value.map((p) => p.project?.id).filter(Boolean)) + const pendingProjectIds = new Set(pending.map((p) => p.project?.id).filter(Boolean)) + const displayProjects = projects.value.map((project) => + project.project?.id && pendingProjectIds.has(project.project.id) + ? { ...project, installing: true } + : project, + ) + const realProjectIds = new Set(displayProjects.map((p) => p.project?.id).filter(Boolean)) const placeholders = pending.filter((item) => !realProjectIds.has(item.project?.id)) - return placeholders.length > 0 ? [...projects.value, ...placeholders] : projects.value + return placeholders.length > 0 ? [...displayProjects, ...placeholders] : displayProjects }) +watch( + () => installFailureRevisionByInstance.value.get(props.instance.id) ?? 0, + (revision, previousRevision) => { + if (revision === previousRevision) return + installingBuffer.value = [] + }, +) + const linkedModpackProject = ref(null) const linkedModpackVersion = ref(null) const linkedModpackOwner = ref(null) @@ -600,19 +613,11 @@ async function updateProject(mod: ContentItem) { try { const updateVersionId = mod.update_version_id! - await update_project(props.instance.id, mod.file_path) - - if (updateVersionId) { - const versionData = await get_version(updateVersionId, 'must_revalidate').catch(handleError) - - if (versionData) { - const instance = await get(props.instance.id).catch(handleError) - - if (instance) { - await installVersionDependencies(instance, versionData, 'update').catch(handleError) - } - } - } + await switch_project_version_with_dependencies( + props.instance.id, + mod.file_path, + updateVersionId, + ) trackEvent('InstanceProjectUpdate', { loader: props.instance.loader, @@ -636,27 +641,9 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions if (!operation) return const oldPath = mod.file_path - const wasDisabled = mod.enabled === false || oldPath.endsWith('.disabled') - let newPath: string | null = null - let shouldRemoveNewOnError = false try { - newPath = await add_project_from_version(props.instance.id, version.id, 'update') - shouldRemoveNewOnError = newPath !== oldPath - - if (wasDisabled) { - newPath = await toggle_disable_project(props.instance.id, newPath) - } - - const instance = await get(props.instance.id).catch(handleError) - if (instance) { - await installVersionDependencies(instance, version, 'update').catch(handleError) - } - - shouldRemoveNewOnError = false - if (newPath !== oldPath) { - await remove_project(props.instance.id, oldPath) - } + await switch_project_version_with_dependencies(props.instance.id, oldPath, version.id) trackEvent('InstanceProjectUpdate', { loader: props.instance.loader, @@ -666,9 +653,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions project_type: mod.project_type, }) } catch (err) { - if (shouldRemoveNewOnError && newPath && newPath !== oldPath) { - await remove_project(props.instance.id, newPath).catch(() => {}) - } handleError(err as Error) } finally { await refreshContentState('must_revalidate') @@ -862,6 +846,15 @@ async function refreshContentState(cacheBehaviour?: CacheBehaviour) { await refreshModpackContentItems(cacheBehaviour) } +watch( + () => installRevisionByInstance.value.get(props.instance.id) ?? 0, + async (revision) => { + if (revision <= handledInstallRevision.value) return + handledInstallRevision.value = revision + await refreshContentState('must_revalidate') + }, +) + async function handleModpackUpdate() { if (!props.instance?.link?.project_id) return @@ -1187,14 +1180,12 @@ provideContentManager({ project: linkedModpackProject.value, projectLink: { path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`, - query: { i: props.instance.id }, }, version: linkedModpackVersion.value ?? undefined, versionLink: linkedModpackProject.value && linkedModpackVersion.value ? { path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}/version/${linkedModpackVersion.value.id}`, - query: { i: props.instance.id }, } : undefined, owner: linkedModpackOwner.value @@ -1257,9 +1248,7 @@ provideContentManager({ title: item.file_name.replace('.disabled', ''), icon_url: null, }, - projectLink: item.project?.id - ? { path: `/project/${item.project.id}`, query: { i: props.instance.id } } - : undefined, + projectLink: item.project?.id ? { path: `/project/${item.project.id}` } : undefined, version: item.version ?? { id: item.file_name, version_number: formatMessage(commonMessages.unknownLabel), @@ -1269,7 +1258,6 @@ provideContentManager({ item.project?.id && item.version?.id ? { path: `/project/${item.project.id}/version/${item.version.id}`, - query: { i: props.instance.id }, } : undefined, owner: item.owner @@ -1289,7 +1277,17 @@ type UnlistenFn = () => void const initialContentReady = loadInitialContent() void initialContentReady.then(restoreModpackContentModalState).catch(handleError) +function getInstallRevision() { + return installRevisionByInstance.value.get(props.instance.id) ?? 0 +} + function loadInitialContent() { + const installRevision = getInstallRevision() + if (installRevision > handledInstallRevision.value) { + handledInstallRevision.value = installRevision + return initProjects('must_revalidate') + } + if (props.preloadedContent && applyContentData(props.preloadedContent)) { return Promise.resolve() } diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue index 933cd5d0f1..802fc1c92e 100644 --- a/apps/app-frontend/src/pages/instance/Worlds.vue +++ b/apps/app-frontend/src/pages/instance/Worlds.vue @@ -192,6 +192,7 @@ import { get_project, get_project_v3 } from '@/helpers/cache.js' import { instance_listener } from '@/helpers/events' import { get_game_versions } from '@/helpers/tags' import type { GameInstance } from '@/helpers/types' +import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds' import { delete_world, get_instance_protocol_version, @@ -220,7 +221,6 @@ import { } from '@/helpers/worlds.ts' import { injectServerInstall } from '@/providers/server-install' import { handleSevereError } from '@/store/error.js' -import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install' const messages = defineMessages({ removeServerTitle: { diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 69a9d6ce37..80d2daa9c0 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -313,12 +313,11 @@ import { import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata' import { get_by_instance_id } from '@/helpers/process' import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' -import { getServerLatency } from '@/helpers/worlds' +import { getServerAddress, getServerLatency } from '@/helpers/worlds' import { injectContentInstall } from '@/providers/content-install' import { injectServerInstall } from '@/providers/server-install' import { createServerInstallContent } from '@/providers/setup/server-install-content' import { useBreadcrumbs } from '@/store/breadcrumbs' -import { getServerAddress } from '@/store/install.js' import { useTheming } from '@/store/state.js' dayjs.extend(relativeTime) diff --git a/apps/app-frontend/src/providers/content-install.ts b/apps/app-frontend/src/providers/content-install.ts index 206cc4ffc7..978cbeaab6 100644 --- a/apps/app-frontend/src/providers/content-install.ts +++ b/apps/app-frontend/src/providers/content-install.ts @@ -11,28 +11,26 @@ import { trackEvent } from '@/helpers/analytics' import { get_organization, get_project, + get_project_many, get_project_v3_many, get_team, get_version_many, } from '@/helpers/cache.js' +import { instance_listener } from '@/helpers/events.js' import { add_project_from_version, check_installed_batch, create, get, get_projects, + install_project_with_dependencies, list, remove_project, + type ResolveContentPlan, } from '@/helpers/instance' import { create_instance_and_install as packInstall } from '@/helpers/pack' import { get_game_versions } from '@/helpers/tags' import type { GameInstance, InstanceLoader } from '@/helpers/types' -import { - findPreferredVersion, - installVersionDependencies, - isVersionCompatible, -} from '@/store/install.js' - interface ModalRef { show: (initialVersionId?: string) => void hide: () => void @@ -43,6 +41,23 @@ interface ModpackAlreadyInstalledModalRef { } export type ContentInstallCallback = (versionId?: string, installedProjectIds?: string[]) => void +type InstallingProjectDisplay = { + id?: string + slug?: string | null + title?: string + name?: string + icon_url?: string | null + project_type?: string + type?: string + organization?: string | null + team?: string +} +type ContentInstallInstanceEvent = { + event: string + instance_id: string + project_ids?: string[] + message?: string +} const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge'] const SUPPORTED_LOADERS: Set = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge']) @@ -53,6 +68,48 @@ const noCompatibleVersionsMessage = defineMessage({ 'No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically.', }) +const RESOLVABLE_PROJECT_TYPES = new Set([ + 'mod', + 'plugin', + 'datapack', + 'resourcepack', + 'shader', + 'modpack', +]) + +function resolveContentType(projectType?: Labrinth.Projects.v2.ProjectType) { + return projectType && RESOLVABLE_PROJECT_TYPES.has(projectType) ? projectType : 'mod' +} + +function isVersionCompatible( + version: Labrinth.Versions.v2.Version, + project: Labrinth.Projects.v2.Project, + instance: GameInstance, +) { + return ( + version.game_versions.includes(instance.game_version) && + (project.project_type === 'mod' + ? version.loaders.includes(instance.loader) || version.loaders.includes('datapack') + : true) + ) +} + +function findPreferredVersion( + versions: Labrinth.Versions.v2.Version[], + project: Labrinth.Projects.v2.Project, + instance: GameInstance, +) { + const projectType = project.project_type ?? 'mod' + + return ( + versions.find( + (v) => + v.game_versions.includes(instance.game_version) && + (projectType === 'mod' ? v.loaders.includes(instance.loader) : true), + ) ?? versions.find((v) => isVersionCompatible(v, project, instance)) + ) +} + function sortLoaders(loaders: string[]): string[] { return loaders.slice().sort((a, b) => { const aIdx = LOADER_ORDER.indexOf(a) @@ -109,6 +166,8 @@ export interface ContentInstallContext { hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean }, ) => Promise installingItems: Ref> + installRevisionByInstance: Ref> + installFailureRevisionByInstance: Ref> } export const [injectContentInstall, provideContentInstall] = createContext( @@ -132,6 +191,8 @@ export function createContentInstall(opts: { const projectInfo = ref(null) const installingItems = ref>(new Map()) + const installRevisionByInstance = ref>(new Map()) + const installFailureRevisionByInstance = ref>(new Map()) const incompatibilityWarningVersions = ref([]) const incompatibilityWarningCurrentGameVersion = ref('') const incompatibilityWarningCurrentLoader = ref('') @@ -253,6 +314,84 @@ export function createContentInstall(opts: { } } + function resolvedProjectIds(plan: ResolveContentPlan) { + return [ + plan.primary.project_id, + ...plan.dependencies.map((dependency) => dependency.project_id), + ] + } + + async function addInstallingItemsForPlan( + instanceId: string, + plan: ResolveContentPlan, + primaryProject: Labrinth.Projects.v2.Project, + primaryVersion: Labrinth.Versions.v2.Version, + ) { + const entries = [plan.primary, ...plan.dependencies] + const projectIds = [...new Set(entries.map((entry) => entry.project_id))] + const versionIds = [...new Set(entries.map((entry) => entry.version_id))] + const projectMap = new Map([ + [primaryProject.id, primaryProject], + ]) + const versionMap = new Map([ + [primaryVersion.id, primaryVersion], + ]) + + const [projects, versions] = await Promise.all([ + get_project_many(projectIds, 'bypass').catch(() => []), + get_version_many(versionIds, 'bypass').catch(() => []), + ]) + + for (const project of projects as InstallingProjectDisplay[]) { + if (project?.id) projectMap.set(project.id, project) + } + for (const version of versions as Labrinth.Versions.v2.Version[]) { + if (version?.id) versionMap.set(version.id, version) + } + + for (const entry of entries) { + const project = projectMap.get(entry.project_id) + const version = versionMap.get(entry.version_id) + addInstallingItem( + instanceId, + { + id: entry.project_id, + slug: project?.slug ?? entry.project_id, + title: project?.title ?? project?.name ?? entry.project_id, + icon_url: project?.icon_url ?? null, + project_type: project?.project_type ?? project?.type ?? primaryProject.project_type, + organization: project?.organization ?? null, + team: project?.team, + }, + version, + ) + } + } + + function markInstanceContentChanged(instanceId: string) { + const next = new Map(installRevisionByInstance.value) + next.set(instanceId, (next.get(instanceId) ?? 0) + 1) + installRevisionByInstance.value = next + } + + function markInstanceContentInstallFailed(instanceId: string) { + const next = new Map(installFailureRevisionByInstance.value) + next.set(instanceId, (next.get(instanceId) ?? 0) + 1) + installFailureRevisionByInstance.value = next + } + + void instance_listener((event: ContentInstallInstanceEvent) => { + if (event.event === 'content_install_finished') { + markInstanceContentChanged(event.instance_id) + removeInstallingItems(event.instance_id, event.project_ids ?? []) + } else if (event.event === 'content_install_failed') { + removeInstallingItems(event.instance_id, event.project_ids ?? []) + markInstanceContentInstallFailed(event.instance_id) + markInstanceContentChanged(event.instance_id) + opts.handleError(event.message ?? 'Failed to install content') + } + }).catch(opts.handleError) + let modalRef: ModalRef | null = null let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null let incompatibilityWarningModalRef: ModalRef | null = null @@ -459,22 +598,24 @@ export function createContentInstall(opts: { if (storeInstance) storeInstance.installing = true - const installedProjectIds: string[] = [] - if (currentProject) { - addInstallingItem(instance.id, currentProject, version) - installedProjectIds.push(currentProject.id) - } + const installedProjectIds: string[] = [currentProject.id] + let plannedProjectIds: string[] = [currentProject.id] + addInstallingItem(instance.id, currentProject, version) try { - await add_project_from_version(instance.id, version.id, 'standalone') - await installVersionDependencies( - selectedInstance, - version, - 'dependency', - (depProject: Labrinth.Projects.v2.Project, depVersion?: Labrinth.Versions.v2.Version) => { - addInstallingItem(instance.id, depProject, depVersion) - installedProjectIds.push(depProject.id) - }, + const request = { + project_id: currentProject.id, + version_id: version.id, + content_type: resolveContentType(currentProject.project_type), + } + const plan = await install_project_with_dependencies(instance.id, request) + plannedProjectIds = resolvedProjectIds(plan) + await addInstallingItemsForPlan(instance.id, plan, currentProject, version) + installedProjectIds.splice( + 0, + installedProjectIds.length, + plan.primary.project_id, + ...plan.dependencies.map((dependency) => dependency.project_id), ) if (storeInstance) { storeInstance.installed = true @@ -492,9 +633,9 @@ export function createContentInstall(opts: { currentCallback(version.id, installedProjectIds) } catch (err) { if (storeInstance) storeInstance.installing = false + removeInstallingItems(instance.id, plannedProjectIds) + markInstanceContentInstallFailed(instance.id) opts.handleError(err) - } finally { - removeInstallingItems(instance.id, installedProjectIds) } } @@ -534,18 +675,23 @@ export function createContentInstall(opts: { if (!incompatibilityWarningInstance || !incompatibilityWarningProject) return incompatibilityWarningInstalling.value = true + addInstallingItem(incompatibilityWarningInstance.id, incompatibilityWarningProject, version) try { await add_project_from_version(incompatibilityWarningInstance.id, version.id, 'standalone') } catch (err) { opts.handleError(err) incompatibilityWarningInstalling.value = false + removeInstallingItems(incompatibilityWarningInstance.id, [incompatibilityWarningProject.id]) + markInstanceContentInstallFailed(incompatibilityWarningInstance.id) return } incompatibilityWarningInstalling.value = false incompatibilityWarningInstalled = true incompatibilityWarningCallback(version.id, [incompatibilityWarningProject.id]) + markInstanceContentChanged(incompatibilityWarningInstance.id) incompatibilityWarningModalRef?.hide() + removeInstallingItems(incompatibilityWarningInstance.id, [incompatibilityWarningProject.id]) trackEvent('ProjectInstall', { loader: incompatibilityWarningInstance.loader, @@ -581,6 +727,7 @@ export function createContentInstall(opts: { loaderCandidates.some((l) => v.loaders.includes(l)), ) ?? currentVersions[0] + let createdInstanceId: string | null = null try { const id = await create( data.name, @@ -591,13 +738,17 @@ export function createContentInstall(opts: { false, ) if (!id) return + createdInstanceId = id + addInstallingItem(id, currentProject!, version) - await add_project_from_version(id, version.id, 'standalone') + const plan = await install_project_with_dependencies(id, { + project_id: currentProject!.id, + version_id: version.id, + content_type: resolveContentType(currentProject!.project_type), + }) + await addInstallingItemsForPlan(id, plan, currentProject!, version) await opts.router.push(`/instance/${encodeURIComponent(id)}`) - const instance = await get(id) - await installVersionDependencies(instance, version, 'dependency') - trackEvent('InstanceCreate', { source: 'ProjectInstallModal', }) @@ -611,9 +762,13 @@ export function createContentInstall(opts: { source: 'ProjectInstallModal', }) - currentCallback(version.id) + currentCallback(version.id, resolvedProjectIds(plan)) modalRef?.hide() } catch (err) { + if (createdInstanceId && currentProject) { + removeInstallingItems(createdInstanceId, [currentProject.id]) + markInstanceContentInstallFailed(createdInstanceId) + } opts.handleError(err) } } @@ -691,20 +846,22 @@ export function createContentInstall(opts: { } const installedProjectIds: string[] = [project.id] + let plannedProjectIds: string[] = [project.id] addInstallingItem(instanceId, project, version) try { - await add_project_from_version(instance.id, version.id, 'standalone') - await installVersionDependencies( - instance, - version, - 'dependency', - ( - depProject: Labrinth.Projects.v2.Project, - depVersion?: Labrinth.Versions.v2.Version, - ) => { - addInstallingItem(instanceId, depProject, depVersion) - installedProjectIds.push(depProject.id) - }, + const request = { + project_id: project.id, + version_id: version.id, + content_type: resolveContentType(project.project_type), + } + const plan = await install_project_with_dependencies(instance.id, request) + plannedProjectIds = resolvedProjectIds(plan) + await addInstallingItemsForPlan(instanceId, plan, project, version) + installedProjectIds.splice( + 0, + installedProjectIds.length, + plan.primary.project_id, + ...plan.dependencies.map((dependency) => dependency.project_id), ) trackEvent('ProjectInstall', { @@ -717,8 +874,10 @@ export function createContentInstall(opts: { source, }) callback(version.id, installedProjectIds) - } finally { - removeInstallingItems(instanceId, installedProjectIds) + } catch (err) { + removeInstallingItems(instanceId, plannedProjectIds) + markInstanceContentInstallFailed(instanceId) + throw err } } else { await showIncompatibilityWarning(instance, project, projectVersions, version, callback) @@ -790,5 +949,7 @@ export function createContentInstall(opts: { handleIncompatibilityWarningCancel, install, installingItems, + installRevisionByInstance, + installFailureRevisionByInstance, } } diff --git a/apps/app-frontend/src/providers/server-install.ts b/apps/app-frontend/src/providers/server-install.ts index 2160dd93ab..5cb80ef587 100644 --- a/apps/app-frontend/src/providers/server-install.ts +++ b/apps/app-frontend/src/providers/server-install.ts @@ -9,9 +9,9 @@ import { get_project, get_project_v3, get_version } from '@/helpers/cache.js' import { create, edit, edit_icon, get, install as installInstance, list } from '@/helpers/instance' import { install_to_existing_instance } from '@/helpers/pack.js' import type { GameInstance } from '@/helpers/types' +import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds' import { start_join_server } from '@/helpers/worlds.ts' import { handleSevereError } from '@/store/error.js' -import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install.js' // eslint-disable-next-line @typescript-eslint/no-explicit-any interface ModalRef void = () => void> { diff --git a/apps/app-frontend/src/store/install.js b/apps/app-frontend/src/store/install.js deleted file mode 100644 index b68266443b..0000000000 --- a/apps/app-frontend/src/store/install.js +++ /dev/null @@ -1,207 +0,0 @@ -// TODO: migrate to content-install.ts DI - -import dayjs from 'dayjs' - -import { get_project, get_version, get_version_many } from '@/helpers/cache.js' -import { add_project_from_version, check_installed } from '@/helpers/instance' -import { - add_server_to_instance, - get_instance_worlds, - resolveManagedServerWorld, -} from '@/helpers/worlds.ts' - -export const findPreferredVersion = (versions, project, instance) => { - // When `project` is passed in from this stack trace: - // - `installVersionDependencies` - // - `install.js/install` - `installVersionDependencies` call - // - // ..then `project` is actually a `Dependency` struct of a cached `Version`. - // `Dependency` does not have a `project_type` field, - // so we default it to `mod`. - // - // If we don't default here, then this `.find` will ignore version/instance - // loader mismatches, and you'll end up e.g. installing NeoForge mods for a - // Fabric instance. - const projectType = project.project_type ?? 'mod' - - // If we can find a version using strictly the instance loader then prefer that - let version = versions.find( - (v) => - v.game_versions.includes(instance.game_version) && - (projectType === 'mod' ? v.loaders.includes(instance.loader) : true), - ) - - if (!version) { - // Otherwise use first compatible version (in addition to versions with the instance loader this includes datapacks) - version = versions.find((v) => isVersionCompatible(v, project, instance)) - } - - return version -} - -export const isVersionCompatible = (version, project, instance) => { - return ( - version.game_versions.includes(instance.game_version) && - (project.project_type === 'mod' - ? version.loaders.includes(instance.loader) || version.loaders.includes('datapack') - : true) - ) -} - -export const installVersionDependencies = async (instance, version, reason, onDepInstalling) => { - const projectNames = new Map() - const storeProjectName = (p) => { - if (p?.id && p.title) projectNames.set(p.id, p.title) - } - - const visitedVersions = new Set() - const announcedProjects = new Set() - const queuedVersionIds = new Set() - const queuedProjectVersions = new Map() - const queuedInstalls = [] - const installedProjectCache = new Map() - - const isProjectInstalled = async (projectId) => { - if (!projectId) return false - if (installedProjectCache.has(projectId)) { - return installedProjectCache.get(projectId) - } - const installed = await check_installed(instance.id, projectId) - installedProjectCache.set(projectId, installed) - return installed - } - - const queueInstall = async (projectId, resolvedVersion, dependentOn) => { - if (!resolvedVersion?.id) return false - - const versionId = resolvedVersion.id - const resolvedProjectId = projectId ?? resolvedVersion.project_id ?? null - - if (resolvedProjectId) { - if (await isProjectInstalled(resolvedProjectId)) return false - - const existingVersionId = queuedProjectVersions.get(resolvedProjectId) - if (existingVersionId && existingVersionId !== versionId) return false - if (existingVersionId === versionId) return false - } - - if (queuedVersionIds.has(versionId)) return false - - queuedVersionIds.add(versionId) - if (resolvedProjectId) { - queuedProjectVersions.set(resolvedProjectId, versionId) - } - queuedInstalls.push({ - versionId, - projectId: resolvedProjectId, - dependentOnVersionId: dependentOn?.id, - }) - return true - } - - const announceDependency = async (projectId, resolvedVersion) => { - if (!onDepInstalling || !projectId) return - if (announcedProjects.has(projectId)) return - - const depProject = await get_project(projectId, 'bypass').catch(() => null) - if (!depProject) return - - storeProjectName(depProject) - onDepInstalling(depProject, resolvedVersion ?? undefined) - announcedProjects.add(projectId) - } - - const resolveDependency = async (dep) => { - let depVersion = null - let depProjectId = dep.project_id ?? null - - if (dep.version_id) { - depVersion = await get_version(dep.version_id, 'bypass').catch(() => null) - if (!depVersion) return null - - depProjectId = depProjectId ?? depVersion.project_id ?? null - if (depProjectId && !projectNames.has(depProjectId)) { - const p = await get_project(depProjectId, 'bypass').catch(() => null) - storeProjectName(p) - } - } else if (dep.project_id) { - const depProject = await get_project(dep.project_id, 'bypass').catch(() => null) - if (!depProject) return null - - storeProjectName(depProject) - - const depVersions = await get_version_many(depProject.versions, 'bypass').catch(() => []) - depVersion = findPreferredVersion( - depVersions.sort((a, b) => dayjs(b.date_published) - dayjs(a.date_published)), - dep, - instance, - ) - if (!depVersion) return null - - depProjectId = dep.project_id - } else { - return null - } - - return { depVersion, depProjectId } - } - - const collectDependenciesForVersion = async (inputVersion) => { - if (!inputVersion?.id || visitedVersions.has(inputVersion.id)) return - visitedVersions.add(inputVersion.id) - - if (inputVersion.project_id && !projectNames.has(inputVersion.project_id)) { - const p = await get_project(inputVersion.project_id, 'bypass').catch(() => null) - storeProjectName(p) - } - - for (const dep of inputVersion.dependencies ?? []) { - if (dep.dependency_type !== 'required') continue - if (dep.project_id === 'P7dR8mSH' && instance.loader === 'quilt') continue - - const resolved = await resolveDependency(dep, inputVersion) - if (!resolved) continue - - const { depVersion, depProjectId } = resolved - const queued = await queueInstall(depProjectId, depVersion, inputVersion) - if (queued && depProjectId) { - await announceDependency(depProjectId, depVersion) - } - - await collectDependenciesForVersion(depVersion) - } - } - - await collectDependenciesForVersion(version) - - if (queuedInstalls.length === 0) return - - const batchSize = 8 - for (let i = 0; i < queuedInstalls.length; i += batchSize) { - const batch = queuedInstalls.slice(i, i + batchSize) - await Promise.all( - batch.map(async ({ versionId, dependentOnVersionId }) => { - await add_project_from_version(instance.id, versionId, reason, dependentOnVersionId) - }), - ) - } -} - -export const getServerAddress = (javaServer) => { - if (!javaServer) return null - const { address } = javaServer - return address -} - -export const ensureManagedServerWorldExists = async (instanceId, serverName, serverAddress) => { - if (!instanceId || !serverAddress) return - try { - const worlds = await get_instance_worlds(instanceId) - const managedWorld = resolveManagedServerWorld(worlds, serverName, serverAddress) - if (!managedWorld) { - await add_server_to_instance(instanceId, serverName, serverAddress, 'prompt') - } - } catch (err) { - console.error('Failed to ensure managed server world exists:', err) - } -} diff --git a/apps/app/build.rs b/apps/app/build.rs index 28e0b2f33d..ce264395c5 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -186,6 +186,8 @@ fn main() { "instance_update_all", "instance_update_project", "instance_add_project_from_version", + "instance_install_project_with_dependencies", + "instance_switch_project_version_with_dependencies", "instance_add_project_from_path", "instance_toggle_disable_project", "instance_remove_project", diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs index 1f539b3e18..57fe01d899 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance.rs @@ -10,6 +10,7 @@ use theseus::data::{ EditInstance as CoreEditInstance, InstanceLaunchOverridesPatch, InstanceLink as CoreInstanceLink, InstanceMetadata, LinkedModpackInfo, }; +use theseus::instance::InstallProjectWithDependenciesRequest; use theseus::instance::QuickPlayType; use theseus::prelude::*; use theseus::server_address::ServerAddress; @@ -39,6 +40,8 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_update_all, instance_update_project, instance_add_project_from_version, + instance_install_project_with_dependencies, + instance_switch_project_version_with_dependencies, instance_add_project_from_path, instance_toggle_disable_project, instance_remove_project, @@ -619,6 +622,32 @@ pub async fn instance_add_project_from_version( .await?) } +#[tauri::command] +pub async fn instance_install_project_with_dependencies( + instance_id: &str, + request: InstallProjectWithDependenciesRequest, +) -> Result { + Ok(theseus::instance::install_project_with_dependencies( + instance_id, + request, + ) + .await?) +} + +#[tauri::command] +pub async fn instance_switch_project_version_with_dependencies( + instance_id: &str, + project_path: &str, + version_id: &str, +) -> Result { + Ok(theseus::instance::switch_project_version_with_dependencies( + instance_id, + project_path, + version_id, + ) + .await?) +} + #[tauri::command] pub async fn instance_add_project_from_path( instance_id: &str, diff --git a/apps/frontend/src/composables/use-server-install-content.ts b/apps/frontend/src/composables/use-server-install-content.ts index 248b7896ac..e3c579d684 100644 --- a/apps/frontend/src/composables/use-server-install-content.ts +++ b/apps/frontend/src/composables/use-server-install-content.ts @@ -401,6 +401,43 @@ export function useServerInstallContent({ ) } + function toResolvePreferences( + preferences?: BrowseInstallPlan['preferences'], + ): Labrinth.Content.v3.ResolutionPreferences { + return { + game_versions: preferences?.gameVersions, + loaders: preferences?.loaders, + } + } + + async function resolveQueuedAddonPlans(plans: BrowseInstallPlan[]) { + const existingProjectIds = getServerInstalledProjectIds() + const resolvedAddons: Array<{ project_id: string; version_id: string }> = [] + + for (const plan of plans) { + const resolved = await client.labrinth.content_v3.resolve({ + project_id: plan.projectId, + version_id: plan.versionId, + content_type: plan.contentType as Labrinth.Content.v3.ContentType, + selected: toResolvePreferences(plan.preferences), + target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)), + existing_project_ids: Array.from(existingProjectIds), + }) + const content = [resolved.primary, ...resolved.dependencies] + + for (const item of content) { + if (existingProjectIds.has(item.project_id)) continue + existingProjectIds.add(item.project_id) + resolvedAddons.push({ + project_id: item.project_id, + version_id: item.version_id, + }) + } + } + + return resolvedAddons + } + function getInstallProjectVersions(projectId: string) { return client.labrinth.versions_v2.getProjectVersions(projectId, { include_changelog: false, @@ -444,15 +481,12 @@ export function useServerInstallContent({ const result = await flushStoredServerAddonInstallQueue({ serverId, worldId, - install: (plans) => - client.archon.content_v1.addAddons( - serverId, - worldId, - plans.map((plan) => ({ - project_id: plan.projectId, - version_id: plan.versionId, - })), - ), + install: async (plans) => { + const addons = await resolveQueuedAddonPlans(plans) + if (addons.length > 0) { + await client.archon.content_v1.addAddons(serverId, worldId, addons) + } + }, onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans), }) diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index f8cf063868..65f96d90e1 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -75,6 +75,7 @@ json-patch = { workspace = true } lettre = { workspace = true } meilisearch-sdk = { workspace = true, features = ["reqwest"] } modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] } +modrinth-content-management = { workspace = true } muralpay = { workspace = true, features = ["client", "mock", "utoipa"] } murmur2 = { workspace = true } paste = { workspace = true } diff --git a/apps/labrinth/src/routes/v3/content/mod.rs b/apps/labrinth/src/routes/v3/content/mod.rs new file mode 100644 index 0000000000..1cec8d82ae --- /dev/null +++ b/apps/labrinth/src/routes/v3/content/mod.rs @@ -0,0 +1,628 @@ +use super::ApiError; +use crate::auth::checks::{ + filter_visible_versions, is_visible_project, is_visible_version, +}; +use crate::auth::get_user_from_headers; +use crate::database::models::ids::DBVersionId; +use crate::database::models::version_item::VersionQueryResult; +use crate::database::models::{DBProject, DBVersion}; +use crate::database::{PgPool, redis::RedisPool}; +use crate::models::pats::Scopes; +use crate::models::projects::{DependencyType, Version}; +use crate::models::users::User; +use crate::queue::session::AuthQueue; +use actix_web::{HttpRequest, post, web}; +use ariadne::ids::base62_impl::parse_base62; +use async_trait::async_trait; +use modrinth_content_management::{ + ContentMetadataProvider, Error as ResolveError, ResolveContentPlan, + ResolveContentRequest, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +const CONTENT_RESOLVE_CACHE_NAMESPACE: &str = "content_resolve"; +const CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE: &str = "content_resolve_heat"; +const CONTENT_RESOLVE_CACHE_SCHEMA_VERSION: &str = "v1"; +const CONTENT_RESOLVE_CACHE_HEAT_WINDOW_SECONDS: i64 = 60 * 60 * 24; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(resolve_content); +} + +#[post("content/resolve")] +async fn resolve_content( + req: HttpRequest, + request: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ | Scopes::VERSION_READ, + ) + .await + .map(|x| x.1) + .ok(); + let cache_public_result = user_option.is_none(); + let mut provider = LabrinthContentProvider { + pool: pool.get_ref(), + redis: redis.get_ref(), + user_option: &user_option, + trace: ResolveContentTrace::default(), + }; + let request = request.into_inner(); + let plan = if cache_public_result { + resolve_content_with_cache(&mut provider, request).await + } else { + modrinth_content_management::resolve_content(&mut provider, request) + .await + } + .map_err(resolve_error_to_api)?; + + Ok(web::Json(plan)) +} + +struct LabrinthContentProvider<'a> { + pool: &'a PgPool, + redis: &'a RedisPool, + user_option: &'a Option, + trace: ResolveContentTrace, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct ResolveContentTrace { + versions: BTreeMap, + project_versions: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CachedResolveContentPlan { + trace: ResolveContentTrace, + plan: ResolveContentPlan, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)] +struct DependencyState { + version_id: Option, + project_id: Option, + file_name: Option, + dependency_type: &'static str, +} + +#[derive(Serialize)] +struct VersionState<'a> { + id: &'a str, + project_id: &'a str, + date_published: String, + dependencies: Vec, + game_versions: Vec<&'a str>, + loaders: Vec<&'a str>, +} + +#[derive(Serialize)] +struct ResolveContentHeatKey<'a> { + project_id: &'a str, + version_id: Option<&'a str>, + content_type: modrinth_content_management::ContentType, +} + +#[async_trait] +impl ContentMetadataProvider for &mut LabrinthContentProvider<'_> { + async fn get_version( + &mut self, + version_id: &str, + ) -> Result, ResolveError> + { + let Some(db_version_id) = parse_version_id(version_id) else { + return Ok(None); + }; + let version = DBVersion::get(db_version_id, self.pool, self.redis) + .await + .map_err(resolve_provider_error)?; + + let Some(version) = version else { + self.record_version(version_id, None); + return Ok(None); + }; + + if !is_visible_version( + &version.inner, + self.user_option, + self.pool, + self.redis, + ) + .await + .map_err(resolve_provider_error)? + { + self.record_version(version_id, None); + return Ok(None); + } + + let version = version_to_resolver(Version::from(version)); + self.record_version(version_id, Some(&version)); + + Ok(Some(version)) + } + + async fn get_project_versions( + &mut self, + project_id: &str, + ) -> Result, ResolveError> { + let project = DBProject::get(project_id, self.pool, self.redis) + .await + .map_err(resolve_provider_error)?; + let Some(project) = project else { + self.record_project_versions(project_id, &[]); + return Ok(Vec::new()); + }; + + if !is_visible_project( + &project.inner, + self.user_option, + self.pool, + false, + ) + .await + .map_err(resolve_provider_error)? + { + self.record_project_versions(project_id, &[]); + return Ok(Vec::new()); + } + + let versions = + DBVersion::get_many(&project.versions, self.pool, self.redis) + .await + .map_err(resolve_provider_error)?; + let versions = + visible_versions(versions, self.user_option, self.pool, self.redis) + .await + .map_err(resolve_provider_error)?; + + let versions = versions + .into_iter() + .map(version_to_resolver) + .collect::>(); + self.record_project_versions(project_id, &versions); + + Ok(versions) + } +} + +impl LabrinthContentProvider<'_> { + fn record_version( + &mut self, + version_id: &str, + version: Option<&modrinth_content_management::Version>, + ) { + self.trace + .versions + .insert(version_id.to_string(), hash_optional_version(version)); + } + + fn record_project_versions( + &mut self, + project_id: &str, + versions: &[modrinth_content_management::Version], + ) { + self.trace + .project_versions + .insert(project_id.to_string(), hash_project_versions(versions)); + } + + fn reset_trace(&mut self) { + self.trace = ResolveContentTrace::default(); + } + + fn trace(&self) -> ResolveContentTrace { + self.trace.clone() + } +} + +async fn resolve_content_with_cache( + provider: &mut LabrinthContentProvider<'_>, + request: ResolveContentRequest, +) -> Result { + let cache_key = content_resolve_cache_key(&request); + let heat_key = content_resolve_heat_key(&request); + let heat = increment_content_resolve_cache_heat(&provider.redis, &heat_key) + .await + .unwrap_or(1); + let cache_expiry = content_resolve_cache_expiry_seconds(heat); + + if let Some(cached) = + get_cached_resolve_content_plan(&provider.redis, &cache_key).await + && validate_cached_trace(provider, &cached.trace).await? + { + set_cached_resolve_content_plan( + &provider.redis, + &cache_key, + &cached, + cache_expiry, + ) + .await; + return Ok(cached.plan); + } + + provider.reset_trace(); + let plan = + modrinth_content_management::resolve_content(&mut *provider, request) + .await?; + let trace = provider.trace(); + set_cached_resolve_content_plan( + &provider.redis, + &cache_key, + &CachedResolveContentPlan { + trace, + plan: plan.clone(), + }, + cache_expiry, + ) + .await; + + Ok(plan) +} + +async fn increment_content_resolve_cache_heat( + redis: &RedisPool, + heat_key: &str, +) -> Option { + let mut redis = match redis.connect().await { + Ok(redis) => redis, + Err(error) => { + tracing::warn!( + "failed to connect to redis for content resolve cache heat: {error}" + ); + return None; + } + }; + + let count = match redis + .incr(CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE, heat_key) + .await + { + Ok(Some(count)) => count, + Ok(None) => 1, + Err(error) => { + tracing::warn!( + "failed to increment content resolve cache heat: {error}" + ); + return None; + } + }; + + if let Err(error) = redis + .set( + CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE, + heat_key, + &count.to_string(), + Some(CONTENT_RESOLVE_CACHE_HEAT_WINDOW_SECONDS), + ) + .await + { + tracing::warn!("failed to refresh content resolve cache heat: {error}"); + } + + Some(count) +} + +async fn get_cached_resolve_content_plan( + redis: &RedisPool, + cache_key: &str, +) -> Option { + let mut redis = match redis.connect().await { + Ok(redis) => redis, + Err(error) => { + tracing::warn!( + "failed to connect to redis for content resolve cache: {error}" + ); + return None; + } + }; + + match redis + .get_deserialized_from_json(CONTENT_RESOLVE_CACHE_NAMESPACE, cache_key) + .await + { + Ok(cached) => cached, + Err(error) => { + tracing::warn!("failed to read content resolve cache: {error}"); + None + } + } +} + +async fn set_cached_resolve_content_plan( + redis: &RedisPool, + cache_key: &str, + cached: &CachedResolveContentPlan, + expiry_seconds: i64, +) { + let mut redis = match redis.connect().await { + Ok(redis) => redis, + Err(error) => { + tracing::warn!( + "failed to connect to redis for content resolve cache: {error}" + ); + return; + } + }; + + if let Err(error) = redis + .set_serialized_to_json( + CONTENT_RESOLVE_CACHE_NAMESPACE, + cache_key, + cached, + Some(expiry_seconds), + ) + .await + { + tracing::warn!("failed to write content resolve cache: {error}"); + } +} + +async fn validate_cached_trace( + provider: &mut LabrinthContentProvider<'_>, + trace: &ResolveContentTrace, +) -> Result { + provider.reset_trace(); + + for (version_id, expected_hash) in &trace.versions { + let Some(db_version_id) = parse_version_id(version_id) else { + return Ok(false); + }; + let version = + DBVersion::get(db_version_id, provider.pool, provider.redis) + .await + .map_err(resolve_provider_error)?; + + let version = if let Some(version) = version { + if is_visible_version( + &version.inner, + provider.user_option, + provider.pool, + provider.redis, + ) + .await + .map_err(resolve_provider_error)? + { + Some(version_to_resolver(Version::from(version))) + } else { + None + } + } else { + None + }; + + if &hash_optional_version(version.as_ref()) != expected_hash { + return Ok(false); + } + } + + for (project_id, expected_hash) in &trace.project_versions { + let versions = + (&mut *provider).get_project_versions(project_id).await?; + + if &hash_project_versions(&versions) != expected_hash { + return Ok(false); + } + } + + Ok(true) +} + +fn content_resolve_cache_key(request: &ResolveContentRequest) -> String { + format!( + "{CONTENT_RESOLVE_CACHE_SCHEMA_VERSION}:{}", + hash_serializable(&normalized_resolve_content_request(request)) + ) +} + +fn content_resolve_heat_key(request: &ResolveContentRequest) -> String { + hash_serializable(&ResolveContentHeatKey { + project_id: &request.project_id, + version_id: request.version_id.as_deref(), + content_type: request.content_type, + }) +} + +fn content_resolve_cache_expiry_seconds(heat: u64) -> i64 { + match heat { + 0..=1 => 60 * 5, + 2..=9 => 60 * 30, + 10..=99 => 60 * 60 * 6, + _ => 60 * 60 * 24, + } +} + +fn normalized_resolve_content_request( + request: &ResolveContentRequest, +) -> ResolveContentRequest { + let mut request = request.clone(); + + request.selected.game_versions.sort(); + request.selected.game_versions.dedup(); + request.selected.loaders.sort(); + request.selected.loaders.dedup(); + request.target.game_versions.sort(); + request.target.game_versions.dedup(); + request.target.loaders.sort(); + request.target.loaders.dedup(); + request.existing_project_ids.sort(); + request.existing_project_ids.dedup(); + + request +} + +fn hash_optional_version( + version: Option<&modrinth_content_management::Version>, +) -> String { + match version { + Some(version) => format!("some:{}", hash_version(version)), + None => "none".to_string(), + } +} + +fn hash_project_versions( + versions: &[modrinth_content_management::Version], +) -> String { + let mut versions = versions + .iter() + .map(|version| (version.id.as_str(), hash_version(version))) + .collect::>(); + versions.sort_by(|a, b| a.0.cmp(b.0)); + + hash_serializable(&versions) +} + +fn hash_version(version: &modrinth_content_management::Version) -> String { + let mut dependencies = version + .dependencies + .iter() + .map(|dependency| DependencyState { + version_id: dependency.version_id.clone(), + project_id: dependency.project_id.clone(), + file_name: dependency.file_name.clone(), + dependency_type: dependency_type_cache_key( + dependency.dependency_type, + ), + }) + .collect::>(); + dependencies.sort(); + + let mut game_versions = version + .game_versions + .iter() + .map(String::as_str) + .collect::>(); + game_versions.sort(); + + let mut loaders = version + .loaders + .iter() + .map(String::as_str) + .collect::>(); + loaders.sort(); + + hash_serializable(&VersionState { + id: &version.id, + project_id: &version.project_id, + date_published: version.date_published.to_rfc3339(), + dependencies, + game_versions, + loaders, + }) +} + +fn dependency_type_cache_key( + dependency_type: modrinth_content_management::DependencyType, +) -> &'static str { + match dependency_type { + modrinth_content_management::DependencyType::Required => "required", + modrinth_content_management::DependencyType::Optional => "optional", + modrinth_content_management::DependencyType::Incompatible => { + "incompatible" + } + modrinth_content_management::DependencyType::Embedded => "embedded", + } +} + +fn hash_serializable(value: &impl Serialize) -> String { + let bytes = serde_json::to_vec(value) + .expect("serializing cache key should not fail"); + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +fn parse_version_id(version_id: &str) -> Option { + parse_base62(version_id) + .ok() + .map(|id| DBVersionId(id as i64)) +} + +async fn visible_versions( + versions: Vec, + user_option: &Option, + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + filter_visible_versions(versions, user_option, pool, redis).await +} + +fn version_to_resolver( + version: Version, +) -> modrinth_content_management::Version { + modrinth_content_management::Version { + id: version.id.to_string(), + project_id: version.project_id.to_string(), + date_published: version.date_published, + dependencies: version + .dependencies + .into_iter() + .map(|dependency| modrinth_content_management::Dependency { + version_id: dependency.version_id.map(|id| id.to_string()), + project_id: dependency.project_id.map(|id| id.to_string()), + file_name: dependency.file_name, + dependency_type: dependency_type_to_resolver( + dependency.dependency_type, + ), + }) + .collect(), + game_versions: version.games, + loaders: version.loaders.into_iter().map(|loader| loader.0).collect(), + } +} + +fn dependency_type_to_resolver( + dependency_type: DependencyType, +) -> modrinth_content_management::DependencyType { + match dependency_type { + DependencyType::Required => { + modrinth_content_management::DependencyType::Required + } + DependencyType::Optional => { + modrinth_content_management::DependencyType::Optional + } + DependencyType::Incompatible => { + modrinth_content_management::DependencyType::Incompatible + } + DependencyType::Embedded => { + modrinth_content_management::DependencyType::Embedded + } + } +} + +fn resolve_provider_error(error: impl std::fmt::Display) -> ResolveError { + ResolveError::Provider(error.to_string()) +} + +fn resolve_error_to_api(error: ResolveError) -> ApiError { + match error { + ResolveError::Provider(message) => { + ApiError::Internal(eyre::eyre!(message)) + } + ResolveError::ProjectNotFound(project_id) => ApiError::Request( + eyre::eyre!("project `{project_id}` was not found"), + ), + ResolveError::VersionNotFound(version_id) => ApiError::Request( + eyre::eyre!("version `{version_id}` was not found"), + ), + ResolveError::VersionProjectMismatch { + version_id, + project_id, + } => ApiError::Request(eyre::eyre!( + "version `{version_id}` does not belong to project `{project_id}`" + )), + ResolveError::NoCompatibleVersion(project_id) => { + ApiError::Request(eyre::eyre!( + "no compatible version was found for project `{project_id}`" + )) + } + } +} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index cb28d96c5b..247a2b654d 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -6,6 +6,7 @@ use serde_json::json; pub mod analytics_event; pub mod analytics_get; pub mod collections; +pub mod content; pub mod friends; pub mod images; pub mod limits; @@ -34,6 +35,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .wrap(default_cors()) .configure(limits::config) .configure(collections::config) + .configure(content::config) .configure(images::config) .configure(notifications::config) .configure(organizations::config) diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 7e857f12c3..10a7c4dc52 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -25,6 +25,7 @@ import { LabrinthAuthV2Module } from './labrinth/auth/v2' import { LabrinthBillingInternalModule } from './labrinth/billing/internal' import { LabrinthCampaignInternalModule } from './labrinth/campaign/internal' import { LabrinthCollectionsModule } from './labrinth/collections' +import { LabrinthContentV3Module } from './labrinth/content/v3' import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal' import { LabrinthFriendsV3Module } from './labrinth/friends/v3' import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal' @@ -94,6 +95,7 @@ export const MODULE_REGISTRY = { labrinth_billing_internal: LabrinthBillingInternalModule, labrinth_campaign_internal: LabrinthCampaignInternalModule, labrinth_collections: LabrinthCollectionsModule, + labrinth_content_v3: LabrinthContentV3Module, labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule, labrinth_friends_v3: LabrinthFriendsV3Module, labrinth_globals_internal: LabrinthGlobalsInternalModule, diff --git a/packages/api-client/src/modules/labrinth/content/v3.ts b/packages/api-client/src/modules/labrinth/content/v3.ts new file mode 100644 index 0000000000..57af987945 --- /dev/null +++ b/packages/api-client/src/modules/labrinth/content/v3.ts @@ -0,0 +1,19 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +export class LabrinthContentV3Module extends AbstractModule { + public getModuleID(): string { + return 'labrinth_content_v3' + } + + public async resolve( + request: Labrinth.Content.v3.ResolveContentRequest, + ): Promise { + return this.client.request('/content/resolve', { + api: 'labrinth', + version: 3, + method: 'POST', + body: request, + }) + } +} diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index 4bc8e150e1..055e0504d1 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -3,6 +3,7 @@ export * from './auth/internal' export * from './auth/v2' export * from './billing/internal' export * from './collections' +export * from './content/v3' export * from './external-projects/internal' export * from './friends/v3' export * from './globals/internal' diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 7cb9b6a370..13c2fb6040 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -2,6 +2,57 @@ import type { RawDecimal } from '../../utils/types' import type { ISO3166 } from '../iso3166/types' export namespace Labrinth { + export namespace Content { + export namespace v3 { + export type ContentType = + | 'mod' + | 'plugin' + | 'datapack' + | 'resourcepack' + | 'shader' + | 'modpack' + + export type ResolutionPreferences = { + game_versions?: string[] + loaders?: string[] + } + + export type ResolveContentRequest = { + project_id: string + version_id?: string | null + content_type: ContentType + selected?: ResolutionPreferences + target?: ResolutionPreferences + existing_project_ids?: string[] + } + + export type ResolveContentPlan = { + primary: ResolvedContent + dependencies: ResolvedContent[] + skipped: SkippedContent[] + } + + export type ResolvedContent = { + project_id: string + version_id: string + dependent_on_version_id?: string | null + } + + export type SkippedContent = { + project_id: string + version_id?: string | null + dependent_on_version_id?: string | null + reason: + | 'already_installed' + | 'duplicate_project' + | 'conflicting_dependency' + | 'no_compatible_version' + | 'missing_version' + | 'quilt_fabric_api' + } + } + } + export namespace Campaign { export namespace Internal { export type CampaignInfo = { diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 0cd7d5d9f7..3ca5cbcffd 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -6,9 +6,9 @@ edition.workspace = true [dependencies] ariadne = { workspace = true } -async-compression = { workspace = true, features = ["gzip", "tokio"] } async-minecraft-ping = { workspace = true, features = ["srv"] } async-recursion = { workspace = true } +async-trait = { workspace = true } async-tungstenite = { workspace = true, features = [ "tokio-runtime", "tokio-rustls-webpki-roots" @@ -46,6 +46,7 @@ heck = { workspace = true } hickory-resolver = { workspace = true } indicatif = { workspace = true, optional = true } itertools = { workspace = true } +modrinth-content-management = { workspace = true } notify = { workspace = true } notify-debouncer-mini = { workspace = true } p256 = { workspace = true, features = ["ecdsa"] } diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs index 935f697efb..c86fa48e9b 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance.rs @@ -23,9 +23,11 @@ pub use self::install::{get_optimal_jre_key, install}; pub use self::lifecycle::{create, duplicate, edit, edit_icon, remove}; pub use self::paths::{get_full_path, get_mod_full_path}; pub use self::projects::{ - add_project_from_path, add_project_from_version, remove_project, - repair_managed_modrinth, toggle_disable_project, update_all_projects, - update_managed_modrinth_version, update_project, + InstallProjectWithDependenciesRequest, add_project_from_path, + add_project_from_version, install_project_with_dependencies, + remove_project, repair_managed_modrinth, + switch_project_version_with_dependencies, toggle_disable_project, + update_all_projects, update_managed_modrinth_version, update_project, }; pub use self::run::{ QuickPlayType, kill, run, try_update_playtime_by_instance_id, diff --git a/packages/app-lib/src/api/instance/projects.rs b/packages/app-lib/src/api/instance/projects.rs index 39a5fd6f96..080f8d549d 100644 --- a/packages/app-lib/src/api/instance/projects.rs +++ b/packages/app-lib/src/api/instance/projects.rs @@ -3,9 +3,21 @@ use crate::event::emit::{emit_instance, emit_loading, init_loading}; use crate::event::{InstancePayloadType, LoadingBarType}; use crate::state::{ProjectType, State}; use crate::util::fetch; +use modrinth_content_management::{ + ContentType, ResolutionPreferences, ResolveContentPlan, +}; use std::collections::HashMap; use std::path::Path; +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub struct InstallProjectWithDependenciesRequest { + pub project_id: String, + pub version_id: Option, + pub content_type: ContentType, + #[serde(default)] + pub selected: ResolutionPreferences, +} + #[tracing::instrument] pub async fn update_all_projects( instance_id: &str, @@ -85,6 +97,115 @@ pub async fn add_project_from_version( Ok(project_path) } +#[tracing::instrument] +pub async fn install_project_with_dependencies( + instance_id: &str, + request: InstallProjectWithDependenciesRequest, +) -> crate::Result { + let state = State::get().await?; + let metadata = get(instance_id).await?.ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let plan = crate::state::instances::commands::resolve_install_plan( + instance_id, + crate::state::instances::commands::InstanceInstallProjectRequest { + project_id: request.project_id, + version_id: request.version_id, + content_type: request.content_type, + selected: request.selected, + }, + &state, + ) + .await?; + + let instance_id = metadata.instance.id; + let project_ids = plan_project_ids(&plan); + let install_plan = plan.clone(); + tokio::spawn(async move { + match crate::state::instances::commands::install_resolved_content_plan( + &instance_id, + &install_plan, + &state, + ) + .await + { + Ok(()) => { + if let Err(error) = emit_instance( + &instance_id, + InstancePayloadType::ContentInstallFinished { + project_ids: project_ids.clone(), + }, + ) + .await + { + tracing::error!( + "Failed to emit content install finished event: {error}" + ); + } + if let Err(error) = + emit_instance(&instance_id, InstancePayloadType::Edited) + .await + { + tracing::error!( + "Failed to emit instance edited event after content install: {error}" + ); + } + } + Err(error) => { + if let Err(emit_error) = emit_instance( + &instance_id, + InstancePayloadType::ContentInstallFailed { + project_ids, + message: error.to_string(), + }, + ) + .await + { + tracing::error!( + "Failed to emit content install failed event: {emit_error}" + ); + } + } + } + }); + + Ok(plan) +} + +fn plan_project_ids(plan: &ResolveContentPlan) -> Vec { + let mut project_ids = Vec::with_capacity(plan.dependencies.len() + 1); + project_ids.push(plan.primary.project_id.clone()); + project_ids.extend( + plan.dependencies + .iter() + .map(|dependency| dependency.project_id.clone()), + ); + project_ids +} + +#[tracing::instrument] +pub async fn switch_project_version_with_dependencies( + instance_id: &str, + project_path: &str, + version_id: &str, +) -> crate::Result { + let state = State::get().await?; + let metadata = get(instance_id).await?.ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let path = + crate::state::instances::commands::switch_project_version_with_dependencies( + instance_id, + project_path, + version_id, + &state, + ) + .await?; + emit_instance(&metadata.instance.id, InstancePayloadType::Edited).await?; + + Ok(path) +} + #[tracing::instrument] pub async fn add_project_from_path( instance_id: &str, diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 4b74a40cd9..1e8144661e 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -28,6 +28,10 @@ pub mod data { Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize, }; pub use ariadne::users::UserStatus; + pub use modrinth_content_management::{ + ContentType, ResolutionPreferences, ResolveContentPlan, + ResolveContentRequest, + }; } pub mod prelude { diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index a721819df3..e025a1bc8e 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -274,6 +274,13 @@ pub enum InstancePayloadType { timestamp: DateTime, }, Edited, + ContentInstallFinished { + project_ids: Vec, + }, + ContentInstallFailed { + project_ids: Vec, + message: String, + }, Removed, } diff --git a/packages/app-lib/src/state/instance_types.rs b/packages/app-lib/src/state/instance_types.rs index 2641e47354..ba3b8f3c09 100644 --- a/packages/app-lib/src/state/instance_types.rs +++ b/packages/app-lib/src/state/instance_types.rs @@ -213,3 +213,14 @@ impl ProjectType { .copied() } } + +impl From for modrinth_content_management::ContentType { + fn from(project_type: ProjectType) -> Self { + match project_type { + ProjectType::Mod => Self::Mod, + ProjectType::DataPack => Self::DataPack, + ProjectType::ResourcePack => Self::ResourcePack, + ProjectType::ShaderPack => Self::Shader, + } + } +} diff --git a/packages/app-lib/src/state/instances/commands/apply_content_install.rs b/packages/app-lib/src/state/instances/commands/apply_content_install.rs index 663f7eff3f..49557808d4 100644 --- a/packages/app-lib/src/state/instances/commands/apply_content_install.rs +++ b/packages/app-lib/src/state/instances/commands/apply_content_install.rs @@ -3,11 +3,18 @@ use crate::state::instances::{ adapters::sqlite::{content_rows, instance_rows}, }; use crate::state::{ - CachedEntry, KnownModrinthFile, ProjectType, State, cache_file_hash, + CacheBehaviour, CachedEntry, Dependency, DependencyType, KnownModrinthFile, + ModLoader, ProjectType, State, Version, cache_file_hash, }; use crate::util::fetch::{self, DownloadMeta, DownloadReason}; use crate::util::io; +use async_trait::async_trait; use bytes::Bytes; +use modrinth_content_management::{ + ContentMetadataProvider, ContentType, Error as ResolveError, + ResolutionPreferences, ResolveContentPlan, ResolveContentRequest, + ResolvedContent, +}; use std::path::{Path, PathBuf}; pub(crate) struct ContentScope { @@ -30,6 +37,276 @@ pub(crate) struct DownloadedProjectVersion { pub version_id: String, } +pub(crate) struct InstanceInstallProjectRequest { + pub project_id: String, + pub version_id: Option, + pub content_type: ContentType, + pub selected: ResolutionPreferences, +} + +struct CachedEntryContentProvider<'a> { + state: &'a State, + cache_behaviour: Option, +} + +#[async_trait] +impl ContentMetadataProvider for CachedEntryContentProvider<'_> { + async fn get_version( + &mut self, + version_id: &str, + ) -> Result, ResolveError> + { + let version = CachedEntry::get_version( + version_id, + self.cache_behaviour, + &self.state.pool, + &self.state.api_semaphore, + ) + .await + .map_err(resolve_provider_error)?; + + Ok(version.map(version_to_resolver)) + } + + async fn get_project_versions( + &mut self, + project_id: &str, + ) -> Result, ResolveError> { + let versions = CachedEntry::get_project_versions( + project_id, + self.cache_behaviour, + &self.state.pool, + &self.state.api_semaphore, + ) + .await + .map_err(resolve_provider_error)?; + + Ok(versions + .unwrap_or_default() + .into_iter() + .map(version_to_resolver) + .collect()) + } +} + +fn resolve_provider_error(error: crate::Error) -> ResolveError { + ResolveError::Provider(error.to_string()) +} + +fn resolver_error(error: ResolveError) -> crate::Error { + crate::ErrorKind::InputError(error.to_string()).into() +} + +fn version_to_resolver( + version: Version, +) -> modrinth_content_management::Version { + modrinth_content_management::Version { + id: version.id, + project_id: version.project_id, + date_published: version.date_published, + dependencies: version + .dependencies + .into_iter() + .map(dependency_to_resolver) + .collect(), + game_versions: version.game_versions, + loaders: version.loaders, + } +} + +fn dependency_to_resolver( + dependency: Dependency, +) -> modrinth_content_management::Dependency { + modrinth_content_management::Dependency { + version_id: dependency.version_id, + project_id: dependency.project_id, + file_name: dependency.file_name, + dependency_type: match dependency.dependency_type { + DependencyType::Required => { + modrinth_content_management::DependencyType::Required + } + DependencyType::Optional => { + modrinth_content_management::DependencyType::Optional + } + DependencyType::Incompatible => { + modrinth_content_management::DependencyType::Incompatible + } + DependencyType::Embedded => { + modrinth_content_management::DependencyType::Embedded + } + }, + } +} + +fn target_preferences( + game_version: String, + loader: ModLoader, + content_type: ContentType, +) -> ResolutionPreferences { + let loader = match content_type { + ContentType::DataPack => "datapack".to_string(), + ContentType::ResourcePack => "minecraft".to_string(), + ContentType::Shader => "iris".to_string(), + _ => loader.as_str().to_string(), + }; + + ResolutionPreferences { + game_versions: vec![game_version], + loaders: vec![loader], + } +} + +pub(crate) async fn resolve_install_plan( + instance_id: &str, + request: InstanceInstallProjectRequest, + state: &State, +) -> crate::Result { + let content_set = + content_rows::get_applied_content_set(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Instance {instance_id} has no applied content set" + )) + })?; + let existing_project_ids = + crate::state::get_installed_project_ids_for_instance( + instance_id, + None, + state, + ) + .await?; + let provider = CachedEntryContentProvider { + state, + cache_behaviour: Some(CacheBehaviour::MustRevalidate), + }; + let content_type = request.content_type; + let request = ResolveContentRequest { + project_id: request.project_id, + version_id: request.version_id, + content_type, + selected: request.selected, + target: target_preferences( + content_set.game_version, + content_set.loader, + content_type, + ), + existing_project_ids, + }; + + modrinth_content_management::resolve_content(provider, request) + .await + .map_err(resolver_error) +} + +pub(crate) async fn install_resolved_content_plan( + instance_id: &str, + plan: &ResolveContentPlan, + state: &State, +) -> crate::Result<()> { + add_resolved_content( + instance_id, + &plan.primary, + DownloadReason::Standalone, + state, + ) + .await?; + for dependency in &plan.dependencies { + add_resolved_content( + instance_id, + dependency, + DownloadReason::Dependency, + state, + ) + .await?; + } + + Ok(()) +} + +pub(crate) async fn switch_project_version_with_dependencies( + instance_id: &str, + project_path: &str, + version_id: &str, + state: &State, +) -> crate::Result { + let version = CachedEntry::get_version( + version_id, + Some(CacheBehaviour::MustRevalidate), + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unable to install version id {version_id}. Not found." + )) + })?; + let content_type = ProjectType::get_from_loaders(version.loaders.clone()) + .map(ContentType::from) + .unwrap_or(ContentType::Mod); + let plan = resolve_install_plan( + instance_id, + InstanceInstallProjectRequest { + project_id: version.project_id, + version_id: Some(version_id.to_string()), + content_type, + selected: ResolutionPreferences::default(), + }, + state, + ) + .await?; + + let was_disabled = project_path.ends_with(".disabled"); + let mut new_path = add_project_from_version( + instance_id, + &plan.primary.version_id, + DownloadReason::Update, + None, + ContentSourceKind::Local, + state, + ) + .await?; + + if was_disabled { + new_path = + toggle_disable_project(instance_id, &new_path, state).await?; + } + + for dependency in &plan.dependencies { + add_resolved_content( + instance_id, + dependency, + DownloadReason::Dependency, + state, + ) + .await?; + } + + if new_path != project_path { + remove_project(instance_id, project_path, state).await?; + } + + Ok(new_path) +} + +async fn add_resolved_content( + instance_id: &str, + content: &ResolvedContent, + reason: DownloadReason, + state: &State, +) -> crate::Result { + add_project_from_version( + instance_id, + &content.version_id, + reason, + content.dependent_on_version_id.clone(), + ContentSourceKind::Local, + state, + ) + .await +} + pub(crate) async fn resolve_content_scope( instance_id: &str, content_set_id: Option<&str>, diff --git a/packages/app-lib/src/state/instances/commands/apply_content_update.rs b/packages/app-lib/src/state/instances/commands/apply_content_update.rs index c7d61f2e77..733e37a7ee 100644 --- a/packages/app-lib/src/state/instances/commands/apply_content_update.rs +++ b/packages/app-lib/src/state/instances/commands/apply_content_update.rs @@ -7,6 +7,7 @@ use crate::state::{ }; use crate::util::fetch::DownloadReason; use futures::stream::{FuturesUnordered, StreamExt}; +use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use super::apply_content_install::{ @@ -500,7 +501,7 @@ async fn resolve_dependency_version( return Ok(None); }; - versions.sort_by(|a, b| b.date_published.cmp(&a.date_published)); + versions.sort_by_key(|version| Reverse(version.date_published)); Ok(find_preferred_dependency_version(&versions, content_set)) } diff --git a/packages/modrinth-content-management/Cargo.toml b/packages/modrinth-content-management/Cargo.toml new file mode 100644 index 0000000000..919213fee3 --- /dev/null +++ b/packages/modrinth-content-management/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "modrinth-content-management" +edition.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/packages/modrinth-content-management/README.md b/packages/modrinth-content-management/README.md new file mode 100644 index 0000000000..e8fb06985a --- /dev/null +++ b/packages/modrinth-content-management/README.md @@ -0,0 +1 @@ +Content management logic shared between Modrinth App & Labrinth diff --git a/packages/modrinth-content-management/src/install.rs b/packages/modrinth-content-management/src/install.rs new file mode 100644 index 0000000000..d6b97c07e2 --- /dev/null +++ b/packages/modrinth-content-management/src/install.rs @@ -0,0 +1,354 @@ +use std::cmp::Reverse; +use std::collections::{HashMap, HashSet}; + +use crate::model::{ + ContentType, Dependency, DependencyType, Error, ResolutionPreferences, + ResolveContentPlan, ResolveContentRequest, ResolvedContent, SkippedContent, + SkippedReason, Version, +}; +use crate::provider::ContentMetadataProvider; + +// Skip Fabric API if you're installing a fabric project onto a quilt instance. +const QUILT_FABRIC_API_EXCEPTION_PROJECT_ID: &str = "P7dR8mSH"; + +pub async fn resolve_content( + mut provider: P, + request: ResolveContentRequest, +) -> Result { + let primary_version = + resolve_primary_version(&mut provider, &request).await?; + let primary = ResolvedContent { + project_id: primary_version.project_id.clone(), + version_id: primary_version.id.clone(), + dependent_on_version_id: None, + }; + let mut resolver = InstallResolver::new(provider, &request); + resolver + .resolve_dependencies_for_version(primary_version) + .await?; + + Ok(ResolveContentPlan { + primary, + dependencies: resolver.dependencies, + skipped: resolver.skipped, + }) +} + +async fn resolve_primary_version( + provider: &mut P, + request: &ResolveContentRequest, +) -> Result { + if let Some(version_id) = &request.version_id { + let version = provider + .get_version(version_id) + .await? + .ok_or_else(|| Error::VersionNotFound(version_id.clone()))?; + + if version.project_id != request.project_id { + return Err(Error::VersionProjectMismatch { + version_id: version.id, + project_id: request.project_id.clone(), + }); + } + + return Ok(version); + } + + let versions = provider.get_project_versions(&request.project_id).await?; + if versions.is_empty() { + return Err(Error::ProjectNotFound(request.project_id.clone())); + } + + select_newest_matching_version( + versions, + request.content_type, + &request.selected, + &request.target, + ) + .ok_or_else(|| Error::NoCompatibleVersion(request.project_id.clone())) +} + +struct InstallResolver<'a, P> { + provider: P, + content_type: ContentType, + selected: &'a ResolutionPreferences, + target: &'a ResolutionPreferences, + existing_project_ids: HashSet, + planned_project_versions: HashMap, + visited_versions: HashSet, + dependencies: Vec, + skipped: Vec, +} + +impl<'a, P: ContentMetadataProvider> InstallResolver<'a, P> { + fn new(provider: P, request: &'a ResolveContentRequest) -> Self { + let mut planned_project_versions = HashMap::new(); + planned_project_versions.insert( + request.project_id.clone(), + request.version_id.clone().unwrap_or_default(), + ); + + Self { + provider, + content_type: request.content_type, + selected: &request.selected, + target: &request.target, + existing_project_ids: request + .existing_project_ids + .iter() + .cloned() + .collect(), + planned_project_versions, + visited_versions: HashSet::new(), + dependencies: Vec::new(), + skipped: Vec::new(), + } + } + + async fn resolve_dependencies_for_version( + &mut self, + version: Version, + ) -> Result<(), Error> { + let mut stack = vec![version]; + + while let Some(version) = stack.pop() { + if !self.visited_versions.insert(version.id.clone()) { + continue; + } + + for dependency in &version.dependencies { + if !matches!( + dependency.dependency_type, + DependencyType::Required + ) { + continue; + } + + if should_skip_quilt_fabric_api(dependency, self.target) { + self.skipped.push(SkippedContent { + project_id: QUILT_FABRIC_API_EXCEPTION_PROJECT_ID + .to_string(), + version_id: dependency.version_id.clone(), + dependent_on_version_id: Some(version.id.clone()), + reason: SkippedReason::QuiltFabricApi, + }); + continue; + } + + let Some(dependency_version) = + self.resolve_dependency_version(dependency).await? + else { + continue; + }; + + let project_id = dependency + .project_id + .clone() + .unwrap_or_else(|| dependency_version.project_id.clone()); + + if self.existing_project_ids.contains(&project_id) { + self.skipped.push(SkippedContent { + project_id, + version_id: Some(dependency_version.id), + dependent_on_version_id: Some(version.id.clone()), + reason: SkippedReason::AlreadyInstalled, + }); + continue; + } + + if let Some(planned_version_id) = + self.planned_project_versions.get(&project_id) + { + let reason = if planned_version_id.is_empty() + || planned_version_id == &dependency_version.id + { + SkippedReason::DuplicateProject + } else { + SkippedReason::ConflictingDependency + }; + + self.skipped.push(SkippedContent { + project_id, + version_id: Some(dependency_version.id), + dependent_on_version_id: Some(version.id.clone()), + reason, + }); + continue; + } + + self.planned_project_versions + .insert(project_id.clone(), dependency_version.id.clone()); + self.dependencies.push(ResolvedContent { + project_id, + version_id: dependency_version.id.clone(), + dependent_on_version_id: Some(version.id.clone()), + }); + stack.push(dependency_version); + } + } + + Ok(()) + } + + async fn resolve_dependency_version( + &mut self, + dependency: &Dependency, + ) -> Result, Error> { + if let Some(version_id) = &dependency.version_id { + let version = self.provider.get_version(version_id).await?; + if version.is_none() { + self.skipped.push(SkippedContent { + project_id: dependency + .project_id + .clone() + .unwrap_or_default(), + version_id: Some(version_id.clone()), + dependent_on_version_id: None, + reason: SkippedReason::MissingVersion, + }); + } + return Ok(version); + } + + let Some(project_id) = &dependency.project_id else { + return Ok(None); + }; + let versions = self.provider.get_project_versions(project_id).await?; + let version = select_newest_matching_version( + versions, + self.content_type, + self.selected, + self.target, + ); + + if version.is_none() { + self.skipped.push(SkippedContent { + project_id: project_id.clone(), + version_id: None, + dependent_on_version_id: None, + reason: SkippedReason::NoCompatibleVersion, + }); + } + + Ok(version) + } +} + +fn select_newest_matching_version( + mut versions: Vec, + content_type: ContentType, + selected: &ResolutionPreferences, + target: &ResolutionPreferences, +) -> Option { + versions.sort_by_key(|version| Reverse(version.date_published)); + let merged = selected.merge(target); + + versions + .iter() + .find(|version| version_matches(version, content_type, &merged)) + .or_else(|| { + versions + .iter() + .find(|version| version_matches(version, content_type, target)) + }) + .cloned() +} + +trait MergePreferences { + fn merge(&self, target: &Self) -> Self; +} + +impl MergePreferences for ResolutionPreferences { + fn merge(&self, target: &Self) -> Self { + Self { + game_versions: if self.game_versions.is_empty() { + target.game_versions.clone() + } else { + self.game_versions.clone() + }, + loaders: if self.loaders.is_empty() { + target.loaders.clone() + } else { + self.loaders.clone() + }, + } + } +} + +fn version_matches( + version: &Version, + content_type: ContentType, + preferences: &ResolutionPreferences, +) -> bool { + matches_game_versions(version, preferences) + && matches_loaders(version, content_type, preferences) +} + +fn matches_game_versions( + version: &Version, + preferences: &ResolutionPreferences, +) -> bool { + preferences.game_versions.is_empty() + || preferences.game_versions.iter().any(|game_version| { + version + .game_versions + .iter() + .any(|candidate| candidate == game_version) + }) +} + +fn matches_loaders( + version: &Version, + content_type: ContentType, + preferences: &ResolutionPreferences, +) -> bool { + if preferences.loaders.is_empty() { + return true; + } + + let direct_match = preferences.loaders.iter().any(|loader| { + version + .loaders + .iter() + .any(|candidate| loaders_match(loader, candidate)) + }); + + if direct_match { + return true; + } + + content_type == ContentType::Mod + && version.loaders.iter().any(|loader| loader == "datapack") +} + +fn loaders_match(expected: &str, candidate: &str) -> bool { + let expected = expected.to_lowercase(); + let candidate = candidate.to_lowercase(); + + expected == candidate + || loader_aliases(&expected).contains(&candidate.as_str()) + || loader_aliases(&candidate).contains(&expected.as_str()) +} + +fn loader_aliases(loader: &str) -> &'static [&'static str] { + match loader { + "neoforge" => &["neo"], + "neo" => &["neoforge"], + "paper" | "purpur" | "spigot" | "bukkit" => { + &["paper", "purpur", "spigot", "bukkit"] + } + _ => &[], + } +} + +fn should_skip_quilt_fabric_api( + dependency: &Dependency, + target: &ResolutionPreferences, +) -> bool { + dependency.project_id.as_deref() + == Some(QUILT_FABRIC_API_EXCEPTION_PROJECT_ID) + && target + .loaders + .iter() + .any(|loader| loaders_match(loader, "quilt")) +} diff --git a/packages/modrinth-content-management/src/lib.rs b/packages/modrinth-content-management/src/lib.rs new file mode 100644 index 0000000000..0e25aa73b3 --- /dev/null +++ b/packages/modrinth-content-management/src/lib.rs @@ -0,0 +1,11 @@ +pub mod install; +pub mod model; +pub mod provider; + +pub use install::resolve_content; +pub use model::{ + ContentType, Dependency, DependencyType, Error, ResolutionPreferences, + ResolveContentPlan, ResolveContentRequest, ResolvedContent, SkippedContent, + SkippedReason, Version, +}; +pub use provider::ContentMetadataProvider; diff --git a/packages/modrinth-content-management/src/model.rs b/packages/modrinth-content-management/src/model.rs new file mode 100644 index 0000000000..10f54d991d --- /dev/null +++ b/packages/modrinth-content-management/src/model.rs @@ -0,0 +1,114 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("metadata provider error: {0}")] + Provider(String), + #[error("project `{0}` was not found")] + ProjectNotFound(String), + #[error("version `{0}` was not found")] + VersionNotFound(String), + #[error("version `{version_id}` does not belong to project `{project_id}`")] + VersionProjectMismatch { + version_id: String, + project_id: String, + }, + #[error("no compatible version was found for project `{0}`")] + NoCompatibleVersion(String), +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ContentType { + Mod, + Plugin, + DataPack, + ResourcePack, + Shader, + ModPack, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct ResolutionPreferences { + #[serde(default)] + pub game_versions: Vec, + #[serde(default)] + pub loaders: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ResolveContentRequest { + pub project_id: String, + pub version_id: Option, + pub content_type: ContentType, + #[serde(default)] + pub selected: ResolutionPreferences, + #[serde(default)] + pub target: ResolutionPreferences, + #[serde(default)] + pub existing_project_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ResolveContentPlan { + pub primary: ResolvedContent, + pub dependencies: Vec, + pub skipped: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ResolvedContent { + pub project_id: String, + pub version_id: String, + pub dependent_on_version_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SkippedContent { + pub project_id: String, + pub version_id: Option, + pub dependent_on_version_id: Option, + pub reason: SkippedReason, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SkippedReason { + AlreadyInstalled, + DuplicateProject, + ConflictingDependency, + NoCompatibleVersion, + MissingVersion, + QuiltFabricApi, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Version { + pub id: String, + pub project_id: String, + pub date_published: DateTime, + #[serde(default)] + pub dependencies: Vec, + #[serde(default)] + pub game_versions: Vec, + #[serde(default)] + pub loaders: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Dependency { + pub version_id: Option, + pub project_id: Option, + pub file_name: Option, + pub dependency_type: DependencyType, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DependencyType { + Required, + Optional, + Incompatible, + Embedded, +} diff --git a/packages/modrinth-content-management/src/provider/mod.rs b/packages/modrinth-content-management/src/provider/mod.rs new file mode 100644 index 0000000000..410fdb677a --- /dev/null +++ b/packages/modrinth-content-management/src/provider/mod.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; + +use crate::model::{Error, Version}; + +#[async_trait] +pub trait ContentMetadataProvider: Send + Sync { + async fn get_version( + &mut self, + version_id: &str, + ) -> Result, Error>; + + async fn get_project_versions( + &mut self, + project_id: &str, + ) -> Result, Error>; +} diff --git a/packages/modrinth-content-management/tests/install.rs b/packages/modrinth-content-management/tests/install.rs new file mode 100644 index 0000000000..28185fb3cf --- /dev/null +++ b/packages/modrinth-content-management/tests/install.rs @@ -0,0 +1,575 @@ +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use async_trait::async_trait; + use chrono::{DateTime, Utc}; + use modrinth_content_management::{ + ContentMetadataProvider, ContentType, Dependency, DependencyType, + Error, ResolutionPreferences, ResolveContentRequest, SkippedReason, + Version, resolve_content, + }; + + const QUILT_FABRIC_API_EXCEPTION_PROJECT_ID: &str = "P7dR8mSH"; + + #[derive(Default)] + struct MemoryProvider { + versions: HashMap, + project_versions: HashMap>, + } + + #[async_trait] + impl ContentMetadataProvider for MemoryProvider { + async fn get_version( + &mut self, + version_id: &str, + ) -> Result, Error> { + Ok(self.versions.get(version_id).cloned()) + } + + async fn get_project_versions( + &mut self, + project_id: &str, + ) -> Result, Error> { + Ok(self + .project_versions + .get(project_id) + .into_iter() + .flatten() + .filter_map(|id| self.versions.get(id)) + .cloned() + .collect()) + } + } + + impl MemoryProvider { + fn with_versions(mut self, versions: Vec) -> Self { + for version in versions { + self.project_versions + .entry(version.project_id.clone()) + .or_default() + .push(version.id.clone()); + self.versions.insert(version.id.clone(), version); + } + + self + } + + fn with_project_versions( + mut self, + project_id: &str, + version_ids: &[&str], + ) -> Self { + self.project_versions.insert( + project_id.to_string(), + version_ids.iter().map(|id| id.to_string()).collect(), + ); + self + } + } + + fn version( + id: &str, + project_id: &str, + date: &str, + game_versions: &[&str], + loaders: &[&str], + dependencies: Vec, + ) -> Version { + Version { + id: id.to_string(), + project_id: project_id.to_string(), + date_published: DateTime::parse_from_rfc3339(date) + .unwrap() + .with_timezone(&Utc), + dependencies, + game_versions: game_versions + .iter() + .map(|v| v.to_string()) + .collect(), + loaders: loaders.iter().map(|v| v.to_string()).collect(), + } + } + + fn dependency( + project_id: Option<&str>, + version_id: Option<&str>, + dependency_type: DependencyType, + ) -> Dependency { + Dependency { + version_id: version_id.map(str::to_string), + project_id: project_id.map(str::to_string), + file_name: None, + dependency_type, + } + } + + fn required_project_dependency(project_id: &str) -> Dependency { + dependency(Some(project_id), None, DependencyType::Required) + } + + fn required_version_dependency(version_id: &str) -> Dependency { + dependency(None, Some(version_id), DependencyType::Required) + } + + fn request(project_id: &str) -> ResolveContentRequest { + ResolveContentRequest { + project_id: project_id.to_string(), + version_id: None, + content_type: ContentType::Mod, + selected: ResolutionPreferences::default(), + target: ResolutionPreferences { + game_versions: vec!["1.20.1".to_string()], + loaders: vec!["fabric".to_string()], + }, + existing_project_ids: Vec::new(), + } + } + + #[tokio::test] + async fn explicit_primary_version_is_used() { + let provider = MemoryProvider::default().with_versions(vec![version( + "v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![], + )]); + let mut request = request("p1"); + request.version_id = Some("v1".to_string()); + + let plan = resolve_content(provider, request).await.unwrap(); + + assert_eq!(plan.primary.version_id, "v1"); + } + + #[tokio::test] + async fn newest_matching_primary_version_is_selected() { + let provider = MemoryProvider::default().with_versions(vec![ + version( + "old", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![], + ), + version( + "new", + "p1", + "2024-02-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![], + ), + ]); + + let plan = resolve_content(provider, request("p1")).await.unwrap(); + + assert_eq!(plan.primary.version_id, "new"); + } + + #[tokio::test] + async fn project_only_dependency_selects_matching_version() { + let provider = MemoryProvider::default().with_versions(vec![ + version( + "p1v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![required_project_dependency("dep")], + ), + version( + "depv1", + "dep", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![], + ), + ]); + + let plan = resolve_content(provider, request("p1")).await.unwrap(); + + assert_eq!(plan.dependencies[0].project_id, "dep"); + assert_eq!( + plan.dependencies[0].dependent_on_version_id.as_deref(), + Some("p1v1") + ); + } + + #[tokio::test] + async fn exact_version_dependency_is_used_even_when_target_mismatches() { + let provider = MemoryProvider::default().with_versions(vec![ + version( + "p1v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![required_version_dependency("depv1")], + ), + version( + "depv1", + "dep", + "2024-01-01T00:00:00Z", + &["1.19.4"], + &["forge"], + vec![], + ), + ]); + + let plan = resolve_content(provider, request("p1")).await.unwrap(); + + assert_eq!(plan.dependencies[0].version_id, "depv1"); + } + + #[tokio::test] + async fn already_installed_dependencies_are_skipped() { + let provider = MemoryProvider::default().with_versions(vec![ + version( + "p1v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![required_project_dependency("dep")], + ), + version( + "depv1", + "dep", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![], + ), + ]); + let mut request = request("p1"); + request.existing_project_ids = vec!["dep".to_string()]; + + let plan = resolve_content(provider, request).await.unwrap(); + + assert!(plan.dependencies.is_empty()); + assert_eq!(plan.skipped[0].reason, SkippedReason::AlreadyInstalled); + } + + #[tokio::test] + async fn duplicate_dependency_projects_are_skipped() { + let provider = MemoryProvider::default().with_versions(vec![ + version( + "p1v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![ + required_project_dependency("dep"), + required_project_dependency("dep"), + ], + ), + version( + "depv1", + "dep", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![], + ), + ]); + + let plan = resolve_content(provider, request("p1")).await.unwrap(); + + assert_eq!(plan.dependencies.len(), 1); + assert_eq!(plan.skipped[0].reason, SkippedReason::DuplicateProject); + } + + #[tokio::test] + async fn conflicting_dependency_versions_are_skipped() { + let provider = MemoryProvider::default().with_versions(vec![ + version( + "p1v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![ + dependency( + Some("dep"), + Some("depv1"), + DependencyType::Required, + ), + dependency( + Some("dep"), + Some("depv2"), + DependencyType::Required, + ), + ], + ), + version( + "depv1", + "dep", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![], + ), + version( + "depv2", + "dep", + "2024-02-01T00:00:00Z", + &["1.20.1"], + &["fabric"], + vec![], + ), + ]); + + let plan = resolve_content(provider, request("p1")).await.unwrap(); + + assert_eq!(plan.dependencies.len(), 1); + assert_eq!( + plan.skipped[0].reason, + SkippedReason::ConflictingDependency + ); + } + + #[tokio::test] + async fn quilt_instances_skip_fabric_api_dependency() { + let provider = MemoryProvider::default().with_versions(vec![version( + "p1v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["quilt"], + vec![required_project_dependency( + QUILT_FABRIC_API_EXCEPTION_PROJECT_ID, + )], + )]); + let mut request = request("p1"); + request.target.loaders = vec!["quilt".to_string()]; + + let plan = resolve_content(provider, request).await.unwrap(); + + assert!(plan.dependencies.is_empty()); + assert_eq!(plan.skipped[0].reason, SkippedReason::QuiltFabricApi); + } + + #[tokio::test] + async fn mods_can_fall_back_to_datapack_versions() { + let provider = MemoryProvider::default().with_versions(vec![version( + "p1v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["datapack"], + vec![], + )]); + + let plan = resolve_content(provider, request("p1")).await.unwrap(); + + assert_eq!(plan.primary.version_id, "p1v1"); + } + + #[tokio::test] + async fn neoforge_matches_neo_loader_alias() { + let provider = MemoryProvider::default().with_versions(vec![version( + "p1v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["neo"], + vec![], + )]); + let mut request = request("p1"); + request.target.loaders = vec!["neoforge".to_string()]; + + let plan = resolve_content(provider, request).await.unwrap(); + + assert_eq!(plan.primary.version_id, "p1v1"); + } + + #[tokio::test] + async fn paper_matches_bukkit_loader_alias() { + let provider = MemoryProvider::default().with_versions(vec![version( + "p1v1", + "p1", + "2024-01-01T00:00:00Z", + &["1.20.1"], + &["bukkit"], + vec![], + )]); + let mut request = request("p1"); + request.content_type = ContentType::Plugin; + request.target.loaders = vec!["paper".to_string()]; + + let plan = resolve_content(provider, request).await.unwrap(); + + assert_eq!(plan.primary.version_id, "p1v1"); + } + + #[tokio::test] + async fn modpack_embedded_dependencies_are_not_installed() { + let provider = MemoryProvider::default() + .with_versions(vec![version( + "Ur9uoHH5", + "shFhR8Vx", + "2025-01-19T06:39:41.349487Z", + &["1.20.1"], + &["fabric"], + vec![ + dependency( + Some("embedded-a"), + None, + DependencyType::Embedded, + ), + dependency( + Some("embedded-b"), + None, + DependencyType::Embedded, + ), + dependency( + Some("embedded-c"), + None, + DependencyType::Embedded, + ), + ], + )]) + .with_project_versions("better-mc-fabric-bmc2", &["Ur9uoHH5"]); + + let plan = resolve_content( + provider, + ResolveContentRequest { + project_id: "better-mc-fabric-bmc2".to_string(), + version_id: None, + content_type: ContentType::ModPack, + selected: ResolutionPreferences::default(), + target: ResolutionPreferences::default(), + existing_project_ids: Vec::new(), + }, + ) + .await + .unwrap(); + + assert_eq!(plan.primary.project_id, "shFhR8Vx"); + assert_eq!(plan.primary.version_id, "Ur9uoHH5"); + assert!(plan.dependencies.is_empty()); + assert!(plan.skipped.is_empty()); + } + + #[tokio::test] + async fn required_dependencies_resolve_transitively() { + let provider = MemoryProvider::default().with_versions(vec![ + version( + "pKFEfjEB", + "DdAlVT8M", + "2026-06-07T21:21:24.772638Z", + &["1.21.1", "1.21.2"], + &["neoforge"], + vec![ + dependency( + Some("LNytGWDc"), + Some("UjX6dr61"), + DependencyType::Required, + ), + dependency( + Some("oWaK0Q19"), + Some("YhZLrAFC"), + DependencyType::Required, + ), + ], + ), + version( + "UjX6dr61", + "LNytGWDc", + "2026-04-21T22:20:03.579201Z", + &["1.21.1"], + &["neoforge"], + vec![], + ), + version( + "YhZLrAFC", + "oWaK0Q19", + "2026-05-21T22:20:03.579201Z", + &["1.21.1"], + &["neoforge"], + vec![ + dependency( + Some("T9PomCSv"), + None, + DependencyType::Required, + ), + dependency( + Some("LNytGWDc"), + Some("UjX6dr61"), + DependencyType::Required, + ), + ], + ), + version( + "hyQUls27", + "T9PomCSv", + "2026-06-17T04:32:50.469591Z", + &["1.21.1"], + &["fabric"], + vec![], + ), + version( + "1L6XJqnY", + "T9PomCSv", + "2026-06-17T04:32:49.279302Z", + &["1.21.1"], + &["neoforge"], + vec![], + ), + ]); + + let plan = resolve_content( + provider, + ResolveContentRequest { + project_id: "DdAlVT8M".to_string(), + version_id: Some("pKFEfjEB".to_string()), + content_type: ContentType::Mod, + selected: ResolutionPreferences::default(), + target: ResolutionPreferences { + game_versions: vec![ + "1.21.1".to_string(), + "1.21.2".to_string(), + ], + loaders: vec!["neoforge".to_string()], + }, + existing_project_ids: Vec::new(), + }, + ) + .await + .unwrap(); + + assert_eq!(plan.primary.project_id, "DdAlVT8M"); + assert_eq!(plan.primary.version_id, "pKFEfjEB"); + assert_eq!(plan.dependencies.len(), 3); + assert!(plan.dependencies.iter().any(|dependency| { + dependency.project_id == "LNytGWDc" + && dependency.version_id == "UjX6dr61" + && dependency.dependent_on_version_id.as_deref() + == Some("pKFEfjEB") + })); + assert!(plan.dependencies.iter().any(|dependency| { + dependency.project_id == "oWaK0Q19" + && dependency.version_id == "YhZLrAFC" + && dependency.dependent_on_version_id.as_deref() + == Some("pKFEfjEB") + })); + assert!(plan.dependencies.iter().any(|dependency| { + dependency.project_id == "T9PomCSv" + && dependency.version_id == "1L6XJqnY" + && dependency.dependent_on_version_id.as_deref() + == Some("YhZLrAFC") + })); + assert_eq!(plan.skipped.len(), 1); + assert_eq!( + plan.skipped[0].reason, + modrinth_content_management::SkippedReason::DuplicateProject + ); + assert_eq!(plan.skipped[0].project_id, "LNytGWDc"); + } +} diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue index 61acdacdf9..12814ff74d 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue @@ -26,9 +26,11 @@ import { } from '#ui/utils/server-content-installing' import { versionChangesGameVersion } from '#ui/utils/version-compatibility' +import type { BrowseInstallPlan } from '../../../shared/browse-tab/composables/install-logic' import { flushStoredServerAddonInstallQueue, getStoredServerAddonInstallQueue, + getTargetInstallPreferences, } from '../../../shared/browse-tab/composables/install-logic' import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue' import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue' @@ -359,6 +361,57 @@ function getAddonInstallKeys(addons: Archon.Content.v1.Addon[]) { return keys } +function getInstalledProjectIds() { + return new Set( + (contentQuery.data.value?.addons ?? []) + .map((addon) => addon.project_id) + .filter((projectId): projectId is string => !!projectId), + ) +} + +function toResolvePreferences( + preferences?: BrowseInstallPlan['preferences'], +): Labrinth.Content.v3.ResolutionPreferences { + return { + game_versions: preferences?.gameVersions, + loaders: preferences?.loaders, + } +} + +async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) { + const existingProjectIds = getInstalledProjectIds() + const resolvedAddons: Array<{ project_id: string; version_id: string }> = [] + + for (const plan of plans) { + const target = getTargetInstallPreferences( + { + gameVersion: server.value?.mc_version, + loader: server.value?.loader, + }, + plan.contentType, + ) + const resolved = await client.labrinth.content_v3.resolve({ + project_id: plan.projectId, + version_id: plan.versionId, + content_type: plan.contentType as Labrinth.Content.v3.ContentType, + selected: toResolvePreferences(plan.preferences), + target: toResolvePreferences(target), + existing_project_ids: Array.from(existingProjectIds), + }) + + for (const item of [resolved.primary, ...resolved.dependencies]) { + if (existingProjectIds.has(item.project_id)) continue + existingProjectIds.add(item.project_id) + resolvedAddons.push({ + project_id: item.project_id, + version_id: item.version_id, + }) + } + } + + return resolvedAddons +} + function addonMatchesPendingInstall( addon: Archon.Content.v1.Addon, pendingInstall: PendingServerContentInstall, @@ -418,15 +471,12 @@ async function flushStoredServerInstalls() { const result = await flushStoredServerAddonInstallQueue({ serverId, worldId: wid, - install: (plans) => - client.archon.content_v1.addAddons( - serverId, - wid, - plans.map((plan) => ({ - project_id: plan.projectId, - version_id: plan.versionId, - })), - ), + install: async (plans) => { + const addons = await resolveStoredServerAddonPlans(plans) + if (addons.length > 0) { + await client.archon.content_v1.addAddons(serverId, wid, addons) + } + }, }) if (!result.ok) {