Compare commits

..
44 changed files with 195 additions and 1608 deletions
+6 -1
View File
@@ -1,7 +1,12 @@
{
"prettier.endOfLine": "lf",
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"editor.detectIndentation": false,
"editor.insertSpaces": false,
"files.eol": "\n",
@@ -89,9 +89,14 @@ const initFiles = async () => {
disabled:
folder === 'profile.json' ||
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric') ||
folder.startsWith('__MACOSX'),
folder.startsWith('.fabric'),
}))
.filter(
(pathData) =>
!pathData.path.includes('.DS_Store') &&
pathData.path !== 'mods/.connector' &&
!pathData.path.startsWith('mods/.connector/'),
)
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
+22 -72
View File
@@ -29,7 +29,7 @@ import {
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
@@ -41,7 +41,6 @@ import {
get_search_results_v3,
get_version_many,
} from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import {
get as getInstance,
@@ -182,28 +181,6 @@ watchServerContextChanges()
await initInstanceContext()
async function refreshInstalledProjectIds() {
if (!route.query.i) return
if (route.query.from === 'worlds') {
const worlds = await get_profile_worlds(route.query.i as string).catch(handleError)
if (!worlds) return
const serverProjectIds = worlds
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
.map((w) => (w as { project_id: string }).project_id)
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
installedProjectIds.value = serverProjectIds
return
}
const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError)
if (!ids) return
debugLog('installedProjectIds loaded', { count: ids.length })
installedProjectIds.value = ids
}
async function initInstanceContext() {
debugLog('initInstanceContext', {
queryI: route.query.i,
@@ -222,7 +199,24 @@ async function initInstanceContext() {
gameVersion: instance.value?.game_version,
})
await refreshInstalledProjectIds()
if (route.query.from === 'worlds') {
get_profile_worlds(route.query.i as string)
.then((worlds) => {
const serverProjectIds = worlds
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
.map((w) => (w as { project_id: string }).project_id)
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
installedProjectIds.value = serverProjectIds
})
.catch(handleError)
} else {
getInstalledProjectIds(route.query.i as string)
.then((ids) => {
debugLog('installedProjectIds loaded', { count: ids?.length })
installedProjectIds.value = ids
})
.catch(handleError)
}
if (instance.value?.linked_data?.project_id) {
debugLog('checking linked project for server status', instance.value.linked_data.project_id)
@@ -811,10 +805,10 @@ function getCardActions(
selectedInstall.versionId,
instance.value ? instance.value.path : null,
'SearchCard',
(versionId, installedProjectIds) => {
(versionId) => {
setProjectInstalling(projectResult.project_id, false)
if (versionId) {
onSearchResultsInstalled(installedProjectIds ?? [projectResult.project_id])
onSearchResultInstalled(projectResult.project_id)
}
},
(profile) => {
@@ -840,19 +834,7 @@ function onSearchResultInstalled(id: string) {
markServerProjectInstalled(id)
return
}
if (!newlyInstalled.value.includes(id)) {
newlyInstalled.value = [...newlyInstalled.value, id]
}
}
function onSearchResultsInstalled(ids: string[]) {
if (isServerContext.value) {
for (const id of ids) {
markServerProjectInstalled(id)
}
return
}
newlyInstalled.value = Array.from(new Set([...newlyInstalled.value, ...ids]))
newlyInstalled.value.push(id)
}
async function search(requestParams: string) {
@@ -984,38 +966,6 @@ if (instance.value?.game_version) {
await searchState.refreshSearch()
type UnlistenFn = () => void
let isUnmounted = false
let unlistenProfiles: UnlistenFn | null = null
onMounted(() => {
profile_listener(async (event: { event: string; profile_path_id: string }) => {
if (
instance.value &&
event.profile_path_id === instance.value.path &&
event.event === 'synced'
) {
await refreshInstalledProjectIds()
await searchState.refreshSearch()
}
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenProfiles = unlisten
})
.catch(handleError)
})
onUnmounted(() => {
isUnmounted = true
unlistenProfiles?.()
})
function getProjectBrowseQuery() {
if (!installContext.value) return undefined
return {
+7 -68
View File
@@ -99,7 +99,7 @@ import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import { get_project_versions, get_version } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import {
@@ -451,63 +451,6 @@ async function removeMod(mod: ContentItem) {
}
}
function isBreakingDependency(dependency: Labrinth.Versions.v2.Dependency) {
return dependency.dependency_type === 'required' || dependency.dependency_type === 'embedded'
}
function dependencyTargetsItem(dependency: Labrinth.Versions.v2.Dependency, item: ContentItem) {
return (
(!!dependency.project_id && dependency.project_id === item.project?.id) ||
('version_id' in dependency &&
!!dependency.version_id &&
dependency.version_id === item.version?.id)
)
}
async function getDeleteDependencyWarning(items: ContentItem[]) {
if (props.isServerInstance) return null
const deletingIds = new Set(items.map(getContentItemId))
const remainingItems = projects.value.filter((item) => !deletingIds.has(getContentItemId(item)))
const versionIds = [
...new Set(remainingItems.map((item) => item.version?.id).filter((id): id is string => !!id)),
]
if (versionIds.length === 0) return null
const versions = (await get_version_many(versionIds).catch((err) => {
handleError(err as Error)
return null
})) as Labrinth.Versions.v2.Version[] | null
if (!versions) return null
const versionsById = new Map(versions.map((version) => [version.id, version]))
const dependents = remainingItems
.map((candidate) => {
const version = candidate.version?.id ? versionsById.get(candidate.version.id) : null
if (!version) return null
const dependencies = items.filter((item) => {
if (!item.project?.id && !item.version?.id) return false
return version.dependencies?.some(
(dependency) =>
isBreakingDependency(dependency) && dependencyTargetsItem(dependency, item),
)
})
return dependencies.length > 0 ? { item: candidate, dependencies } : null
})
.filter(
(dependent): dependent is { item: ContentItem; dependencies: ContentItem[] } =>
dependent !== null,
)
return dependents.length > 0 ? { items, dependents } : null
}
async function updateProject(mod: ContentItem) {
if (!mod.file_path) return
const operation = beginContentOperation(mod)
@@ -538,7 +481,6 @@ async function updateProject(mod: ContentItem) {
})
} catch (err) {
handleError(err as Error)
throw err
} finally {
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
@@ -943,15 +885,13 @@ async function handleModalUpdate(
} else if (updatingProject.value) {
const mod = updatingProject.value
try {
if (mod.has_update && mod.update_version_id === selectedVersion.id) {
await updateProject(mod)
} else {
await switchProjectVersion(mod, selectedVersion)
}
} finally {
resetUpdateState()
if (mod.has_update && mod.update_version_id === selectedVersion.id) {
await updateProject(mod)
} else {
await switchProjectVersion(mod, selectedVersion)
}
resetUpdateState()
}
}
@@ -1141,7 +1081,6 @@ provideContentManager({
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
uploadFiles: handleUploadFiles,
+3 -18
View File
@@ -406,20 +406,6 @@ function buildProjectHref(path, extraQuery = {}) {
return qs ? `${path}?${qs}` : path
}
function buildBrowseHref(path) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (key === 'b') continue
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
params.append(key, String(val))
}
}
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
const projectDescriptionHref = computed(() => buildProjectHref(`/project/${route.params.id}`))
const versionsHref = computed(() =>
buildProjectHref(`/project/${route.params.id}/versions`, instanceFilters.value),
@@ -430,7 +416,7 @@ const projectBrowseBackUrl = computed(() => {
const browsePath = route.query.b
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
return buildBrowseHref(`/browse/${type}`)
return `/browse/${type}`
})
const projectInstallContext = computed(() => {
@@ -739,11 +725,10 @@ async function install(version) {
version,
instance.value ? instance.value.path : null,
'ProjectPage',
(version, installedProjectIds) => {
(version) => {
installing.value = false
const installedIds = installedProjectIds ?? [data.value.id]
if (instance.value && version && installedIds.includes(data.value.id)) {
if (instance.value && version) {
installed.value = true
installedVersion.value = version
}
@@ -5,7 +5,7 @@
:current-title="version.name"
:link-stack="[
{
href: buildProjectHref(`/project/${route.params.id}/versions`),
href: `/project/${route.params.id}/versions`,
label: 'Versions',
},
]"
@@ -249,19 +249,6 @@ const author = computed(() =>
const displayDependencies = ref({})
function buildProjectHref(path) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
params.append(key, String(val))
}
}
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
async function refreshDisplayDependencies() {
const projectIds = new Set()
const versionIds = new Set()
@@ -295,7 +282,7 @@ async function refreshDisplayDependencies() {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `Version ${version.version_number} is ${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}/version/${version.id}`),
link: `/project/${project.slug}/version/${version.id}`,
}
} else {
const project = dependencies.projects.find((obj) => obj.id === dependency.project_id)
@@ -305,7 +292,7 @@ async function refreshDisplayDependencies() {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}`),
link: `/project/${project.slug}`,
}
} else {
return {
@@ -5,7 +5,7 @@
:game-versions="gameVersions"
:versions="versions"
:project="project"
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
:version-link="(version) => `/project/${project.id}/version/${version.id}`"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
@@ -73,7 +73,6 @@ import {
ProjectPageVersions,
} from '@modrinth/ui'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
@@ -110,20 +109,6 @@ defineProps({
})
const { handleError } = injectNotificationManager()
const route = useRoute()
function buildProjectHref(path) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
params.append(key, String(val))
}
}
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
const [loaders, gameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
@@ -42,8 +42,6 @@ interface ModpackAlreadyInstalledModalRef {
show: (instanceName: string, instancePath: string) => void
}
export type ContentInstallCallback = (versionId?: string, installedProjectIds?: string[]) => void
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
@@ -104,7 +102,7 @@ export interface ContentInstallContext {
versionId?: string | null,
instancePath?: string | null,
source?: string,
callback?: ContentInstallCallback,
callback?: (versionId?: string) => void,
createInstanceCallback?: (profile: string) => void,
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
) => Promise<void>
@@ -258,25 +256,25 @@ export function createContentInstall(opts: {
let incompatibilityWarningModalRef: ModalRef | null = null
let currentProject: Labrinth.Projects.v2.Project | null = null
let currentVersions: Labrinth.Versions.v2.Version[] = []
let currentCallback: ContentInstallCallback = () => {}
let currentCallback: (versionId?: string) => void = () => {}
let profileMap: Record<string, GameInstance> = {}
let incompatibilityWarningInstance: GameInstance | null = null
let incompatibilityWarningProject: Labrinth.Projects.v2.Project | null = null
let incompatibilityWarningCallback: ContentInstallCallback = () => {}
let incompatibilityWarningCallback: (versionId?: string) => void = () => {}
let incompatibilityWarningInstalled = false
let pendingModpackInstall: {
project: Labrinth.Projects.v2.Project
version: string
source: string
callback: ContentInstallCallback
callback: (versionId?: string) => void
createInstanceCallback: (profile: string) => void
} | null = null
async function showModInstallModal(
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
onInstall: ContentInstallCallback,
onInstall: (versionId?: string) => void,
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
) {
currentProject = project
@@ -442,7 +440,7 @@ export function createContentInstall(opts: {
if (versionId && storeInstance) {
storeInstance.installed = true
}
currentCallback(versionId, versionId && currentProject ? [currentProject.id] : undefined)
currentCallback(versionId)
}
await showIncompatibilityWarning(
profile,
@@ -489,7 +487,7 @@ export function createContentInstall(opts: {
title: currentProject!.title,
source: 'ProjectInstallModal',
})
currentCallback(version.id, installedProjectIds)
currentCallback(version.id)
} catch (err) {
if (storeInstance) storeInstance.installing = false
opts.handleError(err)
@@ -503,7 +501,7 @@ export function createContentInstall(opts: {
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
version: Labrinth.Versions.v2.Version,
callback: ContentInstallCallback,
callback: (versionId?: string) => void,
) {
incompatibilityWarningInstance = instance
incompatibilityWarningProject = project
@@ -544,7 +542,7 @@ export function createContentInstall(opts: {
incompatibilityWarningInstalling.value = false
incompatibilityWarningInstalled = true
incompatibilityWarningCallback(version.id, [incompatibilityWarningProject.id])
incompatibilityWarningCallback(version.id)
incompatibilityWarningModalRef?.hide()
trackEvent('ProjectInstall', {
@@ -632,7 +630,7 @@ export function createContentInstall(opts: {
versionId?: string | null,
instancePath?: string | null,
source: string = 'unknown',
callback: ContentInstallCallback = () => {},
callback: (versionId?: string) => void = () => {},
createInstanceCallback: (profile: string) => void = () => {},
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
) {
@@ -716,7 +714,7 @@ export function createContentInstall(opts: {
title: project.title,
source,
})
callback(version.id, installedProjectIds)
callback(version.id)
} finally {
removeInstallingItems(instancePath, installedProjectIds)
}
@@ -132,39 +132,23 @@ export function createLoaderParsers(
),
}
},
// Fabric (or Babric for mc version beta 1.7.3)
// Fabric
'fabric.mod.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
const mcDependency = metadata.depends?.minecraft
const mcDependencies = Array.isArray(mcDependency) ? mcDependency : [mcDependency]
let detectedGameVersions = metadata.depends
const detectedGameVersions = metadata.depends
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
: []
const loaders: string[] = []
// Detect Beta 1.7.3 -> Babric
const hasBabricVersion = mcDependencies.some(
(version: string | undefined) => version?.includes('1.0.0-beta.7.3'), // this is fabric's normalized mc version format
)
// Detect 1.3-1.13 -> legacy-fabric
const hasLegacyVersions = detectedGameVersions.some((version) => {
const match = version.match(/^1\.(\d+)/)
return match && parseInt(match[1]) >= 3 && parseInt(match[1]) <= 13
})
if (hasBabricVersion) {
loaders.push('babric')
detectedGameVersions = gameVersions
.filter((version) => version.version === 'b1.7.3')
.map((version) => version.version)
} else if (hasLegacyVersions) {
loaders.push('legacy-fabric')
} else {
loaders.push('fabric')
}
if (hasLegacyVersions) loaders.push('legacy-fabric')
else loaders.push('fabric')
return {
name: `${project.title} ${metadata.version}`,
@@ -50,15 +50,15 @@ export function getGameVersionsMatchingMavenRange(
ranges.push(range)
}
const LESS_THAN_EQUAL = /^\(,([^,]*)]$/
const LESS_THAN = /^\(,([^,]*)\)$/
const EQUAL = /^\[([^,]*)]$/
const GREATER_THAN_EQUAL = /^\[([^,]*),\)$/
const GREATER_THAN = /^\(([^,]*),\)$/
const BETWEEN = /^\(([^,]*),([^,]*)\)$/
const BETWEEN_EQUAL = /^\[([^,]*),([^,]*)]$/
const BETWEEN_LESS_THAN_EQUAL = /^\(([^,]*),([^,]*)]$/
const BETWEEN_GREATER_THAN_EQUAL = /^\[([^,]*),([^,]*)\)$/
const LESS_THAN_EQUAL = /^\(,(.*)]$/
const LESS_THAN = /^\(,(.*)\)$/
const EQUAL = /^\[(.*)]$/
const GREATER_THAN_EQUAL = /^\[(.*),\)$/
const GREATER_THAN = /^\((.*),\)$/
const BETWEEN = /^\((.*),(.*)\)$/
const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
const semverRanges = []
@@ -1,6 +1,7 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
import { getNodeBaseUrl } from '../utils/node-url'
/**
* Node authentication credentials
@@ -107,7 +108,7 @@ export class NodeAuthFeature extends AbstractFeature {
}
private applyAuth(context: RequestContext, auth: NodeAuth): void {
const baseUrl = `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}`
const baseUrl = getNodeBaseUrl(auth.url)
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
context.options.headers = {
+1
View File
@@ -50,4 +50,5 @@ export {
parseSyncEventData,
SseParser,
} from './utils/sse'
export { getNodeWebSocketUrl } from './utils/node-url'
export type { Override, RawDecimal } from './utils/types'
@@ -1,5 +1,6 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import { getNodeBaseUrl } from '../../../utils/node-url'
import type { Archon } from '../../archon/types'
import type { Kyros } from '../types'
@@ -11,7 +12,7 @@ export class KyrosFilesV0Module extends AbstractModule {
}
private getNodeBaseUrl(auth: NodeFsAuth): string {
return `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}`
return getNodeBaseUrl(auth.url)
}
/**
@@ -2,6 +2,7 @@ import mitt from 'mitt'
import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket'
import type { Archon } from '../modules/archon/types'
import { getNodeWebSocketUrl } from '../utils/node-url'
type WSEventMap = {
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
@@ -19,7 +20,7 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(`wss://${auth.url}`)
const ws = new WebSocket(getNodeWebSocketUrl(auth.url))
const connection: WebSocketConnection = {
serverId,
+18
View File
@@ -0,0 +1,18 @@
const NODE_FS_PATH_REGEX = /\/modrinth\/v\d+\/fs\/?$/
const HTTP_SCHEME_REGEX = /^https?:\/\//i
const WS_SCHEME_REGEX = /^wss?:\/\//i
const HTTP_SECURE_SCHEME_REGEX = /^https:\/\//i
const HTTP_INSECURE_SCHEME_REGEX = /^http:\/\//i
export function getNodeBaseUrl(url: string): string {
const baseUrl = url.replace(NODE_FS_PATH_REGEX, '')
return HTTP_SCHEME_REGEX.test(baseUrl) ? baseUrl : `https://${baseUrl}`
}
export function getNodeWebSocketUrl(url: string): string {
if (WS_SCHEME_REGEX.test(url)) return url
if (HTTP_SECURE_SCHEME_REGEX.test(url)) return url.replace(HTTP_SECURE_SCHEME_REGEX, 'wss://')
if (HTTP_INSECURE_SCHEME_REGEX.test(url)) return url.replace(HTTP_INSECURE_SCHEME_REGEX, 'ws://')
return `wss://${url}`
}
-2
View File
@@ -78,7 +78,6 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
),
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
).await?;
@@ -93,7 +92,6 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
None,
None,
Some((&loading_bar, 80.0)),
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -82,7 +82,6 @@ pub async fn import_curseforge(
&thumbnail_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -326,7 +326,6 @@ pub async fn generate_pack_from_version_id(
None,
Some(&download_meta),
Some((&loading_bar, 70.0)),
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -357,7 +356,6 @@ pub async fn generate_pack_from_version_id(
&icon_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -441,7 +441,6 @@ pub async fn install_zipped_mrpack_files(
.collect::<Vec<&str>>(),
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
Some(&download_meta),
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -457,7 +456,6 @@ pub async fn install_zipped_mrpack_files(
project.path.as_str(),
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
ProjectType::get_from_parent_folder(&path),
None,
&state.pool,
)
.await?;
@@ -516,7 +514,6 @@ pub async fn install_zipped_mrpack_files(
ProjectType::get_from_parent_folder(
relative_override_file_path.as_str(),
),
None,
&state.pool,
)
.await?;
@@ -113,7 +113,6 @@ pub async fn profile_create(
icon,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
+6 -37
View File
@@ -547,7 +547,6 @@ pub async fn add_project_from_path(
bytes::Bytes::from(file),
None,
project_type,
None,
&state.io_semaphore,
&state.pool,
)
@@ -667,7 +666,7 @@ pub async fn export_mrpack(
}
// File is not in the config file, add it to the .mrpack zip
if path.is_file() && is_path_exportable(&relative_path) {
if path.is_file() {
let mut file = File::open(&path)
.await
.map_err(|e| IOError::with_path(e, &path))?;
@@ -696,30 +695,6 @@ pub async fn export_mrpack(
Ok(())
}
fn is_path_exportable(relative_path: &SafeRelativeUtf8UnixPathBuf) -> bool {
if relative_path.ends_with(".DS_Store") {
return false;
}
if relative_path.starts_with("mods/.connector/")
|| relative_path.starts_with(".sable/natives/")
|| relative_path.starts_with("local/crash_assistant/")
|| relative_path.starts_with("mods/mcef-libraries/")
|| relative_path.starts_with("mods/mcef-cache/")
|| relative_path.starts_with("config/super_resolution/libraries/")
|| relative_path.starts_with("config/Veinminer/update/")
|| relative_path.starts_with("config/epicfight/native/")
|| relative_path.starts_with("essential/")
|| relative_path.starts_with(".mixin.out/")
|| relative_path.starts_with(".fabric/")
|| relative_path.starts_with("__MACOSX/")
{
return false;
}
true
}
// Given a folder path, populate a Vec of all the subfolders and files, at most 2 layers deep
// profile
// -- folder1
@@ -751,20 +726,14 @@ pub async fn get_pack_export_candidates(
.await
.map_err(|e| IOError::with_path(e, &profile_base_dir))?
{
let relative =
pack_get_relative_path(&profile_base_dir, &entry.path())?;
if !is_path_exportable(&relative) {
continue;
}
path_list.push(relative);
path_list.push(pack_get_relative_path(
&profile_base_dir,
&entry.path(),
)?);
}
} else {
// One layer of files/folders if its a file
let relative = pack_get_relative_path(&profile_base_dir, &path)?;
if !is_path_exportable(&relative) {
continue;
}
path_list.push(relative);
path_list.push(pack_get_relative_path(&profile_base_dir, &path)?);
}
}
Ok(path_list)
+2 -2
View File
@@ -68,8 +68,8 @@ pub enum ErrorKind {
#[error("Error fetching URL: {0}")]
FetchError(#[from] reqwest::Error),
#[error("Too many API errors, try again in {0} minutes")]
ApiIsDownError(u32),
#[error("Too many API errors; temporarily blocked")]
ApiIsDownError,
#[error("{0}")]
LabrinthError(LabrinthError),
+4 -18
View File
@@ -88,7 +88,6 @@ pub async fn download_version_info(
&version.url,
None,
None,
None,
&st.api_semaphore,
&st.pool,
)
@@ -100,7 +99,6 @@ pub async fn download_version_info(
&loader.url,
None,
None,
None,
&st.api_semaphore,
&st.pool,
)
@@ -151,7 +149,6 @@ pub async fn download_client(
&client_download.url,
Some(&client_download.sha1),
None,
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -192,7 +189,6 @@ pub async fn download_assets_index(
&version.asset_index.url,
None,
None,
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -243,7 +239,7 @@ pub async fn download_assets(
async {
if !resource_path.exists() || force {
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), None, None, &st.fetch_semaphore, &st.pool))
.get_or_try_init(|| fetch(&url, Some(hash), None, &st.fetch_semaphore, &st.pool))
.await?;
write(&resource_path, resource, &st.io_semaphore).await?;
tracing::trace!("Fetched asset with hash {hash}");
@@ -257,7 +253,7 @@ pub async fn download_assets(
if with_legacy && !resource_path.exists() || force {
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), None, None, &st.fetch_semaphore, &st.pool))
.get_or_try_init(|| fetch(&url, Some(hash), None, &st.fetch_semaphore, &st.pool))
.await?;
write(&resource_path, resource, &st.io_semaphore).await?;
tracing::trace!("Fetched legacy asset with hash {hash}");
@@ -332,7 +328,6 @@ pub async fn download_libraries(
&native.url,
Some(&native.sha1),
None,
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -378,7 +373,6 @@ pub async fn download_libraries(
&artifact.url,
Some(&artifact.sha1),
None,
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -415,15 +409,8 @@ pub async fn download_libraries(
// failed download here is not a fatal condition.
//
// See DEV-479.
match fetch(
&url,
None,
None,
None,
&st.fetch_semaphore,
&st.pool,
)
.await
match fetch(&url, None, None, &st.fetch_semaphore, &st.pool)
.await
{
Ok(bytes) => {
write(&path, &bytes, &st.io_semaphore).await?;
@@ -483,7 +470,6 @@ pub async fn download_log_config(
&log_download.url,
Some(&log_download.sha1),
None,
None,
&st.fetch_semaphore,
&st.pool,
)
+2 -78
View File
@@ -372,16 +372,6 @@ pub struct CachedFileHash {
pub size: u64,
pub hash: String,
pub project_type: Option<ProjectType>,
#[serde(default)]
pub project_id: Option<String>,
#[serde(default)]
pub version_id: Option<String>,
}
#[derive(Clone, Copy, Debug)]
pub struct KnownModrinthFile<'a> {
pub project_id: &'a str,
pub version_id: &'a str,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -1090,7 +1080,6 @@ impl CachedEntry {
method: Method,
api_url: &str,
url: &str,
uri_path: Option<&'static str>,
keys: &DashSet<impl Display + Eq + Hash + Serialize>,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
@@ -1113,7 +1102,6 @@ impl CachedEntry {
url,
None,
None,
uri_path,
fetch_semaphore,
pool,
)
@@ -1124,12 +1112,11 @@ impl CachedEntry {
}
macro_rules! fetch_original_values {
($type:ident, $api_url:expr, $url_suffix:expr, $uri_path:expr, $cache_variant:path) => {{
($type:ident, $api_url:expr, $url_suffix:expr, $cache_variant:path) => {{
let mut results = fetch_many_batched(
Method::GET,
$api_url,
&format!("{}?ids=", $url_suffix),
$uri_path,
&keys,
&fetch_semaphore,
&pool,
@@ -1185,7 +1172,7 @@ impl CachedEntry {
}
macro_rules! fetch_original_value {
($type:ident, $api_url:expr, $url_suffix:expr, $uri_path:expr, $cache_variant:path) => {{
($type:ident, $api_url:expr, $url_suffix:expr, $cache_variant:path) => {{
vec![(
$cache_variant(
fetch_json(
@@ -1193,7 +1180,6 @@ impl CachedEntry {
&*format!("{}{}", $api_url, $url_suffix),
None,
None,
$uri_path,
&fetch_semaphore,
pool,
)
@@ -1211,7 +1197,6 @@ impl CachedEntry {
Project,
env!("MODRINTH_API_URL"),
"projects",
Some("/v2/projects"),
CacheValue::Project
)
}
@@ -1220,7 +1205,6 @@ impl CachedEntry {
ProjectV3,
env!("MODRINTH_API_URL_V3"),
"projects",
Some("/v3/projects"),
CacheValue::ProjectV3
)
}
@@ -1229,7 +1213,6 @@ impl CachedEntry {
Version,
env!("MODRINTH_API_URL"),
"versions",
Some("/v2/versions"),
CacheValue::Version
)
}
@@ -1238,7 +1221,6 @@ impl CachedEntry {
User,
env!("MODRINTH_API_URL"),
"users",
Some("/v2/users"),
CacheValue::User
)
}
@@ -1247,7 +1229,6 @@ impl CachedEntry {
Method::GET,
env!("MODRINTH_API_URL_V3"),
"teams?ids=",
Some("/v3/teams"),
&keys,
fetch_semaphore,
pool,
@@ -1287,7 +1268,6 @@ impl CachedEntry {
Method::GET,
env!("MODRINTH_API_URL_V3"),
"organizations?ids=",
Some("/v3/organizations"),
&keys,
fetch_semaphore,
pool,
@@ -1347,7 +1327,6 @@ impl CachedEntry {
"algorithm": "sha1",
"hashes": &keys,
})),
Some("/v2/version_files"),
fetch_semaphore,
pool,
)
@@ -1414,7 +1393,6 @@ impl CachedEntry {
url,
None,
None,
None,
fetch_semaphore,
pool,
)
@@ -1443,7 +1421,6 @@ impl CachedEntry {
"minecraft/v{}/manifest.json",
daedalus::minecraft::CURRENT_FORMAT_VERSION
),
None,
CacheValue::MinecraftManifest
)
}
@@ -1452,7 +1429,6 @@ impl CachedEntry {
Categories,
env!("MODRINTH_API_URL"),
"tag/category",
Some("/v2/tag/category"),
CacheValue::Categories
)
}
@@ -1461,7 +1437,6 @@ impl CachedEntry {
ReportTypes,
env!("MODRINTH_API_URL"),
"tag/report_type",
Some("/v2/tag/report_type"),
CacheValue::ReportTypes
)
}
@@ -1470,7 +1445,6 @@ impl CachedEntry {
Loaders,
env!("MODRINTH_API_URL"),
"tag/loader",
Some("/v2/tag/loader"),
CacheValue::Loaders
)
}
@@ -1479,7 +1453,6 @@ impl CachedEntry {
GameVersions,
env!("MODRINTH_API_URL"),
"tag/game_version",
Some("/v2/tag/game_version"),
CacheValue::GameVersions
)
}
@@ -1488,7 +1461,6 @@ impl CachedEntry {
DonationPlatforms,
env!("MODRINTH_API_URL"),
"tag/donation_platform",
Some("/v2/tag/donation_platform"),
CacheValue::DonationPlatforms
)
}
@@ -1531,8 +1503,6 @@ impl CachedEntry {
project_type: ProjectType::get_from_parent_folder(
&full_path,
),
project_id: None,
version_id: None,
})
.get_entry(),
true,
@@ -1647,7 +1617,6 @@ impl CachedEntry {
"game_versions": [game_version],
"version_types": version_types
})),
Some("/v2/version_files/update_many"),
fetch_semaphore,
pool,
)
@@ -1684,32 +1653,13 @@ impl CachedEntry {
let versions = variation.remove(hash);
if let Some(versions) = versions {
let mut emitted_update = false;
for version in versions {
let version_id = version.id.clone();
let target_hash = version
.files
.iter()
.find(|file| file.primary)
.or_else(|| version.files.first())
.and_then(|file| file.hashes.get("sha1"))
.map(String::as_str);
// Some update responses point at a different version ID for the exact installed file.
let same_file =
target_hash == Some(hash.as_str());
vals.push((
CacheValue::Version(version).get_entry(),
false,
));
if same_file {
continue;
}
emitted_update = true;
vals.push((
CacheValue::FileUpdate(CachedFileUpdate {
hash: hash.clone(),
@@ -1726,16 +1676,6 @@ impl CachedEntry {
true,
));
}
if !emitted_update {
vals.push((
CacheValueType::FileUpdate
.get_empty_entry(format!(
"{hash}-{loaders_key}-{channel_policy_key}-{game_version}"
)),
true,
));
}
} else {
vals.push((
CacheValueType::FileUpdate.get_empty_entry(
@@ -1773,7 +1713,6 @@ impl CachedEntry {
url,
None,
None,
Some("/v2/search"),
fetch_semaphore,
pool,
)
@@ -1815,7 +1754,6 @@ impl CachedEntry {
&url,
None,
None,
Some("/v2/project/:id/version"),
fetch_semaphore,
pool,
)
@@ -1867,7 +1805,6 @@ impl CachedEntry {
url,
None,
None,
Some("/v3/search"),
fetch_semaphore,
pool,
)
@@ -2124,7 +2061,6 @@ pub async fn cache_file_hash(
path: &str,
known_hash: Option<&str>,
project_type: Option<ProjectType>,
known_modrinth_file: Option<KnownModrinthFile<'_>>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let size = bytes.len();
@@ -2141,7 +2077,6 @@ pub async fn cache_file_hash(
size as u64,
hash,
project_type,
known_modrinth_file,
exec,
)
.await
@@ -2153,17 +2088,8 @@ pub async fn cache_file_hash_metadata(
size: u64,
hash: String,
project_type: Option<ProjectType>,
known_modrinth_file: Option<KnownModrinthFile<'_>>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let (project_id, version_id) =
known_modrinth_file.map_or((None, None), |metadata| {
(
Some(metadata.project_id.to_string()),
Some(metadata.version_id.to_string()),
)
});
// Streamed extraction already computed these values, so avoid buffering the file just to cache them.
CachedEntry::upsert_many(
&[CacheValue::FileHash(CachedFileHash {
@@ -2171,8 +2097,6 @@ pub async fn cache_file_hash_metadata(
size,
hash,
project_type,
project_id,
version_id,
})
.get_entry()],
exec,
-3
View File
@@ -331,7 +331,6 @@ impl FriendsSocket {
concat!(env!("MODRINTH_API_URL_V3"), "friends"),
None,
None,
Some("/v3/friends"),
semaphore,
exec,
)
@@ -360,7 +359,6 @@ impl FriendsSocket {
None,
None,
None,
Some("/v3/friend/:user_id"),
semaphore,
exec,
)
@@ -394,7 +392,6 @@ impl FriendsSocket {
None,
None,
None,
Some("/v3/friend/:user_id"),
semaphore,
exec,
)
@@ -895,7 +895,6 @@ async fn get_modpack_identifiers(
&[&primary_file.url],
primary_file.hashes.get("sha1").map(|s| s.as_str()),
Some(&download_meta),
None,
fetch_semaphore,
pool,
)
@@ -225,10 +225,6 @@ where
ProjectType::get_from_parent_folder(
&full_path,
),
project_id: Some(
version.project_id.clone(),
),
version_id: Some(version.id.clone()),
},
));
}
-2
View File
@@ -36,7 +36,6 @@ impl ModrinthCredentials {
Some(("Authorization", &*creds.session)),
None,
None,
Some("/v2/session/refresh"),
semaphore,
exec,
)
@@ -229,7 +228,6 @@ async fn fetch_info(
Some(("Authorization", token)),
None,
None,
Some("/v2/user"),
semaphore,
exec,
)
+2 -215
View File
@@ -2,8 +2,8 @@ use super::settings::{Hooks, MemorySettings, WindowSize};
use crate::profile::get_full_path;
use crate::state::server_join_log::JoinLogEntry;
use crate::state::{
CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, KnownModrinthFile,
ReleaseChannel, Version, cache_file_hash,
CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, ReleaseChannel,
cache_file_hash,
};
use crate::util;
use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon};
@@ -1022,16 +1022,6 @@ impl Profile {
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
let file_info_by_hash = Self::resolve_installed_file_metadata(
&file_hashes,
&keys,
file_info_by_hash,
self,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let file_hashes = file_hashes
.into_iter()
@@ -1090,16 +1080,6 @@ impl Profile {
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
let file_info_by_hash = Self::resolve_installed_file_metadata(
&file_hashes,
&keys,
file_info_by_hash,
self,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let installed_channels = Self::get_installed_update_channels(
&file_info_by_hash,
@@ -1351,192 +1331,6 @@ impl Profile {
)
}
async fn resolve_installed_file_metadata(
file_hashes: &[CachedFileHash],
scan_files: &[InitialScanFile],
mut file_info_by_hash: HashMap<String, CachedFile>,
profile: &Profile,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<HashMap<String, CachedFile>> {
let scan_files_by_path: HashMap<&str, &InitialScanFile> = scan_files
.iter()
.map(|file| (file.path.as_str(), file))
.collect();
struct MetadataCandidate {
hash: String,
project_id: String,
version_id: String,
file_name: String,
project_type: ProjectType,
}
let mut candidates = Vec::new();
let mut version_ids = HashSet::new();
for file_hash in file_hashes {
if let (Some(project_id), Some(version_id)) =
(&file_hash.project_id, &file_hash.version_id)
{
file_info_by_hash.insert(
file_hash.hash.clone(),
CachedFile {
hash: file_hash.hash.clone(),
project_id: project_id.clone(),
version_id: version_id.clone(),
},
);
continue;
}
let Some(file_info) = file_info_by_hash.get(&file_hash.hash) else {
continue;
};
let Some(scan_file) = scan_files_by_path
.get(file_hash.path.trim_end_matches(".disabled"))
else {
continue;
};
version_ids.insert(file_info.version_id.clone());
candidates.push(MetadataCandidate {
hash: file_hash.hash.clone(),
project_id: file_info.project_id.clone(),
version_id: file_info.version_id.clone(),
file_name: scan_file.file_name.clone(),
project_type: scan_file.project_type,
});
}
let version_ids_ref =
version_ids.iter().map(|id| id.as_str()).collect::<Vec<_>>();
let versions_by_id: HashMap<String, Version> =
CachedEntry::get_version_many(
&version_ids_ref,
None,
pool,
fetch_semaphore,
)
.await?
.into_iter()
.map(|version| (version.id.clone(), version))
.collect();
let mut project_versions_by_id: HashMap<String, Option<Vec<Version>>> =
HashMap::new();
for candidate in candidates {
if versions_by_id.get(&candidate.version_id).is_some_and(
|version| {
Self::version_matches_file(
version,
&candidate.hash,
&candidate.file_name,
candidate.project_type,
profile,
)
},
) {
continue;
}
if !project_versions_by_id.contains_key(&candidate.project_id) {
let versions = CachedEntry::get_project_versions(
&candidate.project_id,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
project_versions_by_id
.insert(candidate.project_id.clone(), versions);
}
let Some(Some(versions)) =
project_versions_by_id.get(&candidate.project_id)
else {
continue;
};
if let Some(version) = Self::find_matching_file_version(
versions,
&candidate.hash,
&candidate.file_name,
candidate.project_type,
profile,
) {
file_info_by_hash.insert(
candidate.hash.clone(),
CachedFile {
hash: candidate.hash,
project_id: version.project_id.clone(),
version_id: version.id.clone(),
},
);
}
}
Ok(file_info_by_hash)
}
fn find_matching_file_version<'a>(
versions: &'a [Version],
hash: &str,
file_name: &str,
project_type: ProjectType,
profile: &Profile,
) -> Option<&'a Version> {
versions.iter().find(|version| {
Self::version_matches_file(
version,
hash,
file_name,
project_type,
profile,
)
})
}
fn version_matches_file(
version: &Version,
hash: &str,
file_name: &str,
project_type: ProjectType,
profile: &Profile,
) -> bool {
version.game_versions.contains(&profile.game_version)
&& Self::version_loaders_match_profile(
version,
project_type,
profile,
)
&& version.files.iter().any(|file| {
file.hashes.get("sha1").is_some_and(|sha1| sha1 == hash)
&& (file.primary
|| file.filename
== file_name.trim_end_matches(".disabled"))
})
}
fn version_loaders_match_profile(
version: &Version,
project_type: ProjectType,
profile: &Profile,
) -> bool {
if project_type == ProjectType::Mod {
version
.loaders
.iter()
.any(|loader| loader == profile.loader.as_str())
} else {
version.loaders.iter().any(|loader| {
project_type.get_loaders().contains(&loader.as_str())
})
}
}
#[tracing::instrument(skip(pool))]
pub async fn add_project_version(
profile_path: &str,
@@ -1588,7 +1382,6 @@ impl Profile {
&file.url,
file.hashes.get("sha1").map(|x| &**x),
Some(&download_meta),
None,
fetch_semaphore,
pool,
)
@@ -1600,10 +1393,6 @@ impl Profile {
bytes,
file.hashes.get("sha1").map(|x| &**x),
ProjectType::get_from_loaders(version.loaders.clone()),
Some(KnownModrinthFile {
project_id: &version.project_id,
version_id: &version.id,
}),
io_semaphore,
pool,
)
@@ -1619,7 +1408,6 @@ impl Profile {
bytes: bytes::Bytes,
hash: Option<&str>,
project_type: Option<ProjectType>,
known_modrinth_file: Option<KnownModrinthFile<'_>>,
io_semaphore: &IoSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<String> {
@@ -1667,7 +1455,6 @@ impl Profile {
&project_path,
hash,
Some(project_type),
known_modrinth_file,
exec,
)
.await?;
+16 -97
View File
@@ -10,7 +10,7 @@ use rand::Rng;
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
@@ -50,48 +50,20 @@ pub struct IoSemaphore(pub Semaphore);
pub struct FetchSemaphore(pub Semaphore);
struct FetchFence {
inner: Mutex<HashMap<&'static str, FenceInner>>,
inner: Mutex<FenceInner>,
}
impl FetchFence {
pub fn is_blocked(&self, key: &'static str) -> bool {
self.inner
.lock()
.entry(key)
.or_insert_with(FenceInner::new)
.is_blocked()
pub fn is_blocked(&self) -> bool {
self.inner.lock().is_blocked()
}
pub fn record_ok(&self, key: &'static str) {
self.inner
.lock()
.entry(key)
.or_insert_with(FenceInner::new)
.record_ok()
pub fn record_ok(&self) {
self.inner.lock().record_ok()
}
pub fn record_fail(&self, key: &'static str) {
self.inner
.lock()
.entry(key)
.or_insert_with(FenceInner::new)
.record_fail()
}
pub fn latest_block_minutes(&self) -> u32 {
let now = Utc::now();
self.inner
.lock()
.values()
.filter_map(|fence| fence.block_until)
.filter(|until| *until > now)
.max()
.map(|until| {
let seconds = until.signed_duration_since(now).num_seconds();
(seconds.max(0) as u32).div_ceil(60).max(1)
})
.unwrap_or(1)
pub fn record_fail(&self) {
self.inner.lock().record_fail()
}
}
@@ -182,7 +154,7 @@ impl FenceInner {
static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
LazyLock::new(|| FetchFence {
inner: Mutex::new(HashMap::new()),
inner: Mutex::new(FenceInner::new()),
});
fn reqwest_client_builder() -> reqwest::ClientBuilder {
@@ -212,7 +184,6 @@ pub async fn fetch(
url: &str,
sha1: Option<&str>,
download_meta: Option<&DownloadMeta>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Bytes> {
@@ -224,7 +195,6 @@ pub async fn fetch(
None,
download_meta,
None,
uri_path,
semaphore,
exec,
)
@@ -236,7 +206,6 @@ pub async fn fetch_with_client(
url: &str,
sha1: Option<&str>,
download_meta: Option<&DownloadMeta>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
client: &reqwest::Client,
@@ -249,7 +218,6 @@ pub async fn fetch_with_client(
None,
download_meta,
None,
uri_path,
semaphore,
exec,
client,
@@ -263,7 +231,6 @@ pub async fn fetch_json<T>(
url: &str,
sha1: Option<&str>,
json_body: Option<serde_json::Value>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<T>
@@ -271,8 +238,7 @@ where
T: DeserializeOwned,
{
let result = fetch_advanced(
method, url, sha1, json_body, None, None, None, uri_path, semaphore,
exec,
method, url, sha1, json_body, None, None, None, semaphore, exec,
)
.await?;
let value = serde_json::from_slice(&result)?;
@@ -291,7 +257,6 @@ pub async fn fetch_advanced(
header: Option<(&str, &str)>,
download_meta: Option<&DownloadMeta>,
loading_bar: Option<(&LoadingBarId, f64)>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Bytes> {
@@ -303,7 +268,6 @@ pub async fn fetch_advanced(
header,
download_meta,
loading_bar,
uri_path,
semaphore,
exec,
&INSECURE_REQWEST_CLIENT,
@@ -322,7 +286,6 @@ pub async fn fetch_advanced_with_client(
header: Option<(&str, &str)>,
download_meta: Option<&DownloadMeta>,
loading_bar: Option<(&LoadingBarId, f64)>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
client: &reqwest::Client,
@@ -331,7 +294,6 @@ pub async fn fetch_advanced_with_client(
let is_api_url = url.starts_with(env!("MODRINTH_API_URL"))
|| url.starts_with(env!("MODRINTH_API_URL_V3"));
let fence_key = if is_api_url { uri_path } else { None };
let creds = if header
.as_ref()
@@ -347,13 +309,8 @@ pub async fn fetch_advanced_with_client(
.map(|m| (DOWNLOAD_META_HEADER.to_string(), m.to_header_value()));
for attempt in 1..=(FETCH_ATTEMPTS + 1) {
if let Some(fence_key) = fence_key
&& GLOBAL_FETCH_FENCE.is_blocked(fence_key)
{
return Err(ErrorKind::ApiIsDownError(
GLOBAL_FETCH_FENCE.latest_block_minutes(),
)
.into());
if is_api_url && GLOBAL_FETCH_FENCE.is_blocked() {
return Err(ErrorKind::ApiIsDownError.into());
}
let mut req = client.request(method.clone(), url);
@@ -379,8 +336,8 @@ pub async fn fetch_advanced_with_client(
match result {
Ok(resp) => {
if resp.status().is_server_error() {
if let Some(fence_key) = fence_key {
GLOBAL_FETCH_FENCE.record_fail(fence_key);
if is_api_url {
GLOBAL_FETCH_FENCE.record_fail();
}
if attempt <= FETCH_ATTEMPTS {
@@ -443,8 +400,8 @@ pub async fn fetch_advanced_with_client(
tracing::trace!("Done downloading URL {url}");
if let Some(fence_key) = fence_key {
GLOBAL_FETCH_FENCE.record_ok(fence_key);
if is_api_url {
GLOBAL_FETCH_FENCE.record_ok();
}
return Ok(bytes);
@@ -470,7 +427,6 @@ pub async fn fetch_mirrors(
mirrors: &[&str],
sha1: Option<&str>,
download_meta: Option<&DownloadMeta>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Bytes> {
@@ -485,7 +441,6 @@ pub async fn fetch_mirrors(
mirror,
sha1,
download_meta,
uri_path,
semaphore,
exec,
&REQWEST_CLIENT,
@@ -665,42 +620,6 @@ mod tests {
assert!(fence.is_blocked());
}
#[test]
fn test_fetch_fence_keys_are_independent() {
let fence = FetchFence {
inner: Mutex::new(HashMap::new()),
};
for _ in 0..FenceInner::FAILURE_THRESHOLD {
fence.record_fail("/v3/version_file/:sha1/update");
}
assert!(fence.is_blocked("/v3/version_file/:sha1/update"));
assert!(!fence.is_blocked("/v3/project/:id"));
}
#[test]
fn test_fetch_fence_latest_block_minutes() {
let fence = FetchFence {
inner: Mutex::new(HashMap::new()),
};
{
let mut inner = fence.inner.lock();
inner.insert("/expired", FenceInner::new());
inner.get_mut("/expired").unwrap().block_until =
Some(Utc::now() - TimeDelta::minutes(1));
inner.insert("/short", FenceInner::new());
inner.get_mut("/short").unwrap().block_until =
Some(Utc::now() + TimeDelta::seconds(61));
inner.insert("/long", FenceInner::new());
inner.get_mut("/long").unwrap().block_until =
Some(Utc::now() + TimeDelta::seconds(140));
}
assert_eq!(fence.latest_block_minutes(), 3);
}
#[test]
fn test_fence_block_after_4_fails_with_oks() {
// Update tests if the FenceInner constants change
-25
View File
@@ -10,31 +10,6 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-06-16T18:58:45+00:00`,
product: 'app',
version: '0.14.7',
body: `## Added
- Warning modal before deleting content that other content in the instance depends on.
## Changed
- Improved folder filtering when exporting a modpack from an instance.
- Improved error messages when parts of the Modrinth API are unavailable.
## Fixed
- Fixed the Content tab showing updates for installed content when the recommended version used the same file as installed.
- Fixed automatically installed dependencies not appearing as installed when installing content from the Discover page.
- Fixed the search filter for older game versions.
- Fixed bulk action content modals sometimes saying no projects were selected.
- Fixed the Content tab multi-select bar shifting when modals were opened.`,
},
{
date: `2026-06-16T18:58:45+00:00`,
product: 'web',
body: `## Fixed
- Fixed Babric project versions being detected as Fabric versions.
- Fixed the search filter for older game versions.`,
},
{
date: `2026-06-11T19:05:19+00:00`,
product: 'app',
@@ -61,7 +61,6 @@ interface Props {
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
inline?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -83,14 +82,12 @@ const props = withDefaults(defineProps<Props>(), {
showCheckbox: false,
hideDelete: false,
hideActions: false,
inline: false,
})
const selected = defineModel<boolean>('selected')
const emit = defineEmits<{
'update:enabled': [value: boolean]
select: [value: boolean, event?: MouseEvent]
delete: [event: MouseEvent]
update: []
switchVersion: []
@@ -127,10 +124,8 @@ const deleteHovered = ref(false)
<template>
<div
role="row"
class="flex items-center justify-between"
class="flex h-[74px] items-center justify-between gap-4 px-3"
:class="{
'h-[74px] gap-4 px-3': !inline,
'gap-3': inline,
'opacity-50 grayscale': disabled && !installing,
'opacity-50': installing,
}"
@@ -146,7 +141,7 @@ const deleteHovered = ref(false)
:model-value="selected ?? false"
:aria-label="formatMessage(messages.selectProject, { project: project.title })"
class="shrink-0"
@update:model-value="(value, event) => emit('select', value, event)"
@update:model-value="selected = $event"
/>
<div
@@ -155,7 +150,7 @@ const deleteHovered = ref(false)
>
<div
v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined"
class="relative flex shrink-0 items-center"
class="relative shrink-0"
>
<Avatar
:src="project.icon_url"
@@ -185,7 +180,6 @@ const deleteHovered = ref(false)
>
{{ project.title }}
</AutoLink>
<slot name="title-badges" />
<Tooltip
v-if="isClientOnly"
theme="dismissable-prompt"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { computed, getCurrentInstance, ref, toRef } from 'vue'
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, toRef } from 'vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import { useVIntl } from '#ui/composables/i18n'
@@ -119,14 +119,26 @@ function toggleSelectAll() {
}
const lastSelectedIndex = ref<number | null>(null)
const shiftHeld = ref(false)
function toggleItemSelection(
itemId: string,
selected: boolean,
index?: number,
event?: MouseEvent,
) {
if (selected && event?.shiftKey && lastSelectedIndex.value !== null && index !== undefined) {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Shift') shiftHeld.value = true
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === 'Shift') shiftHeld.value = false
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
})
function toggleItemSelection(itemId: string, selected: boolean, index?: number) {
if (selected && shiftHeld.value && lastSelectedIndex.value !== null && index !== undefined) {
const start = Math.min(lastSelectedIndex.value, index)
const end = Math.max(lastSelectedIndex.value, index)
const rangeIds = props.items.slice(start, end + 1).map((item) => item.id)
@@ -285,9 +297,8 @@ function handleSort(column: ContentCardTableSortColumn) {
'border-0 border-t border-solid border-surface-4',
visibleRange.start + idx === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@select="
(val, event) =>
toggleItemSelection(item.id, val ?? false, visibleRange.start + idx, event)
@update:selected="
(val) => toggleItemSelection(item.id, val ?? false, visibleRange.start + idx)
"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
@@ -344,7 +355,7 @@ function handleSort(column: ContentCardTableSortColumn) {
'border-0 border-t border-solid border-surface-4',
index === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@select="(val, event) => toggleItemSelection(item.id, val ?? false, index, event)"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false, index)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
@update="emit('update', item.id)"
@@ -6,12 +6,15 @@
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
})
"
fade="warning"
:fade="props.variant === 'server' ? 'warning' : 'danger'"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
<Admonition
:type="props.variant === 'server' ? 'warning' : 'critical'"
:header="formatMessage(messages.admonitionHeader)"
>
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<InlineBackupCreator
@@ -29,7 +32,7 @@
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<ButtonStyled :color="props.variant === 'server' ? 'orange' : 'red'">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@@ -1,371 +0,0 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
fade="danger"
max-width="560px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
{{
visibleItems.length === 1
? formatMessage(messages.singleAdmonitionBody, {
project:
visibleItems[0]?.project.title ?? formatMessage(commonMessages.unknownLabel),
context: contextLabel,
})
: formatMessage(messages.bulkAdmonitionBody, { context: contextLabel })
}}
</Admonition>
<div v-if="visibleItems.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.deletingLabel) }}</span>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDeletingTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="deletingListRef"
class="flex flex-col gap-2 overflow-y-auto max-h-[212px]"
@scroll="checkDeletingScrollState"
>
<div v-for="item in visibleItems" :key="item.id" :class="modalContentCardClasses">
<ContentCardItem
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
:version-link="item.versionLink"
:owner="item.owner"
hide-actions
inline
/>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDeletingBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
<div v-if="visibleDependents.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.affectedDependentsLabel, { count: visibleDependents.length })
}}</span>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDependentTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="dependentListRef"
class="flex max-h-[212px] flex-col gap-2 overflow-y-auto"
@scroll="checkDependentScrollState"
>
<div
v-for="dependent in visibleDependents"
:key="dependent.item.id"
:class="modalContentCardClasses"
>
<ContentCardItem
:project="dependent.item.project"
:project-link="dependent.item.projectLink"
:version="dependent.item.version"
:version-link="dependent.item.versionLink"
:owner="dependent.item.owner"
hide-actions
inline
>
<template #title-badges>
<span class="flex min-w-0 flex-wrap items-center gap-1">
<span
v-for="dependency in dependent.dependencies"
:key="dependency.id"
:title="dependency.project.title"
>
<span class="truncate text-xs text-secondary mr-0.5"
>({{ dependency.project.title }})</span
>
</span>
</span>
</template>
</ContentCardItem>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDependentBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whatHappensLabel)
}}</span>
<ul class="m-0 list-disc pl-6 text-primary">
<li class="leading-6 marker:text-secondary">
{{ formatMessage(messages.effectDependentContent) }}
</li>
<li class="leading-6 marker:text-secondary">
{{ formatMessage(messages.effectInstance, { context: contextLabel }) }}
</li>
</ul>
<Checkbox
v-model="disableDependentsAfterDeleting"
:label="formatMessage(messages.disableDependentsLabel)"
label-class="font-medium text-primary"
class="mt-1"
/>
</div>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupName"
hide-shift-click-hint
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="confirm"
>
<TrashIcon aria-hidden="true" />
{{ deleteButtonLabel }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import { computed, nextTick, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useScrollIndicator } from '#ui/composables/scroll-indicator'
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
import type { ContentCardTableItem } from '../../types'
import ContentCardItem from '../ContentCardItem.vue'
import InlineBackupCreator from './InlineBackupCreator.vue'
export interface ContentDependencyWarningDependent {
item: ContentCardTableItem
dependencies: ContentCardTableItem[]
}
const props = withDefaults(
defineProps<{
items?: ContentCardTableItem[]
dependents?: ContentDependencyWarningDependent[]
itemType: string
variant?: 'instance' | 'server'
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>(),
{
items: () => [],
dependents: () => [],
variant: 'instance',
backupTip: undefined,
actionDisabled: false,
actionDisabledTooltip: undefined,
},
)
const emit = defineEmits<{
(e: 'delete', disableDependentsAfterDeleting: boolean): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'content.dependency-warning.header',
defaultMessage: 'Dependency warning',
},
admonitionHeader: {
id: 'content.dependency-warning.admonition-header',
defaultMessage: 'This content is required by other content',
},
singleAdmonitionBody: {
id: 'content.dependency-warning.single-admonition-body',
defaultMessage:
'{project} is installed as a dependency. Deleting it may break your {context} or stop dependent content from loading correctly.',
},
bulkAdmonitionBody: {
id: 'content.dependency-warning.bulk-admonition-body',
defaultMessage:
'Some selected projects are installed as dependencies. Deleting them may break your {context} or stop dependent content from loading correctly.',
},
deletingLabel: {
id: 'content.dependency-warning.deleting-label',
defaultMessage: 'Deleting',
},
affectedDependentsLabel: {
id: 'content.dependency-warning.affected-dependents-label',
defaultMessage: 'Affected {count, plural, one {project} other {projects}}',
},
whatHappensLabel: {
id: 'content.dependency-warning.what-happens-label',
defaultMessage: 'What happens?',
},
effectDependentContent: {
id: 'content.dependency-warning.effect-dependent-content',
defaultMessage: 'Dependent content may fail to load or may disable itself',
},
effectInstance: {
id: 'content.dependency-warning.effect-instance',
defaultMessage: 'Your {context} may crash, refuse to start, or behave unexpectedly',
},
deleteAnywayButton: {
id: 'content.dependency-warning.delete-anyway-button',
defaultMessage: 'Delete anyway',
},
deleteManyAnywayButton: {
id: 'content.dependency-warning.delete-many-anyway-button',
defaultMessage: 'Delete {count, number} {itemType} anyway',
},
disableDependentsLabel: {
id: 'content.dependency-warning.disable-dependents-label',
defaultMessage: 'Disable dependents after deleting',
},
instanceContext: {
id: 'content.dependency-warning.context.instance',
defaultMessage: 'instance',
},
serverContext: {
id: 'content.dependency-warning.context.server',
defaultMessage: 'server',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const deletingListRef = ref<HTMLElement | null>(null)
const dependentListRef = ref<HTMLElement | null>(null)
const visibleItems = ref<ContentCardTableItem[]>(props.items)
const visibleDependents = ref<ContentDependencyWarningDependent[]>(props.dependents)
const visibleItemType = ref(props.itemType)
const disableDependentsAfterDeleting = ref(false)
const buttonsDisabled = ref(false)
const modalContentCardClasses = 'rounded-xl border border-solid border-surface-5 p-4 !bg-surface-2'
const {
showTopFade: showDeletingTopFade,
showBottomFade: showDeletingBottomFade,
checkScrollState: checkDeletingScrollState,
forceCheck: forceCheckDeletingScroll,
} = useScrollIndicator(deletingListRef)
const {
showTopFade: showDependentTopFade,
showBottomFade: showDependentBottomFade,
checkScrollState: checkDependentScrollState,
forceCheck: forceCheckDependentScroll,
} = useScrollIndicator(dependentListRef)
const contextLabel = computed(() =>
formatMessage(props.variant === 'server' ? messages.serverContext : messages.instanceContext),
)
const backupName = computed(() =>
props.backupTip ? `Before deletion (${props.backupTip})` : 'Before deletion',
)
const deleteButtonLabel = computed(() => {
if (visibleItems.value.length <= 1) return formatMessage(messages.deleteAnywayButton)
return formatMessage(messages.deleteManyAnywayButton, {
count: visibleItems.value.length,
itemType: formatContentTypeSentence(
formatMessage,
visibleItemType.value,
visibleItems.value.length,
),
})
})
async function show() {
await nextTick()
visibleItems.value = props.items
visibleDependents.value = props.dependents
visibleItemType.value = props.itemType
disableDependentsAfterDeleting.value = false
buttonsDisabled.value = false
modal.value?.show()
await nextTick()
forceCheckDeletingScroll()
forceCheckDependentScroll()
}
function hide() {
modal.value?.hide()
}
function confirm() {
if (props.actionDisabled || buttonsDisabled.value) return
modal.value?.hide()
emit('delete', disableDependentsAfterDeleting.value)
}
defineExpose({
show,
hide,
})
</script>
@@ -8,7 +8,6 @@ export { default as ConfirmModpackUpdateModal } from './components/modals/Confir
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
export { default as ConfirmUnlinkModal } from './components/modals/ConfirmUnlinkModal.vue'
export { default as ContentDependencyWarningModal } from './components/modals/ContentDependencyWarningModal.vue'
export type {
ContentInstallInstance,
ContentInstallProjectInfo,
@@ -34,7 +34,6 @@ import ContentSelectionBar from './components/ContentSelectionBar.vue'
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
import ContentDependencyWarningModal from './components/modals/ContentDependencyWarningModal.vue'
import {
getClientWarningType,
isClientOnlyEnvironment,
@@ -320,73 +319,21 @@ const hasOutdatedProjects = computed(() => {
// Deletion
const pendingDeletionItems = ref<ContentItem[]>([])
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
const contentDependencyWarningModal = ref<InstanceType<typeof ContentDependencyWarningModal>>()
const pendingDependencyWarningItems = ref<ContentCardTableItem[]>([])
const pendingDependencyWarningDependents = ref<
Array<{
item: ContentCardTableItem
dependencies: ContentCardTableItem[]
}>
>([])
const pendingDependencyWarningDisableTargets = ref<ContentItem[]>([])
function mapToDisplayItem(item: ContentItem) {
return {
...ctx.mapToTableItem(item),
id: getItemId(item),
function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
pendingDeletionItems.value = [item]
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
}
}
}
async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
if (items.length === 0) return
pendingDeletionItems.value = items
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
pendingDependencyWarningDisableTargets.value = []
const deletingIds = new Set(items.map(getItemId))
const warning = ctx.getDeleteDependencyWarning
? await Promise.resolve()
.then(() => ctx.getDeleteDependencyWarning!(items))
.catch(() => null)
: null
if (warning) {
const remainingDependents = warning.dependents.filter(
(dependent) => !deletingIds.has(getItemId(dependent.item)),
)
if (remainingDependents.length === 0) {
showDeletionConfirmation(event)
return
}
const relevantDependencyIds = new Set(
remainingDependents.flatMap((dependent) => dependent.dependencies.map(getItemId)),
)
const warningItems = items.filter((item) => relevantDependencyIds.has(getItemId(item)))
if (warningItems.length === 0) {
showDeletionConfirmation(event)
return
}
pendingDependencyWarningItems.value = warningItems.map(mapToDisplayItem)
pendingDependencyWarningDependents.value = remainingDependents.map((dependent) => ({
item: mapToDisplayItem(dependent.item),
dependencies: dependent.dependencies
.filter((dependency) => relevantDependencyIds.has(getItemId(dependency)))
.map(mapToDisplayItem),
}))
pendingDependencyWarningDisableTargets.value = remainingDependents.map(
(dependent) => dependent.item,
)
contentDependencyWarningModal.value?.show()
return
}
showDeletionConfirmation(event)
}
function showDeletionConfirmation(event?: MouseEvent) {
function showBulkDeleteModal(event?: MouseEvent) {
pendingDeletionItems.value = [...selectedItems.value]
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
@@ -394,51 +341,6 @@ function showDeletionConfirmation(event?: MouseEvent) {
}
}
async function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
await promptDeleteItems([item], event)
}
}
async function showBulkDeleteModal(event?: MouseEvent) {
await promptDeleteItems([...selectedItems.value], event)
}
async function confirmDependencyWarningDelete(disableDependentsAfterDeleting: boolean) {
if (disableDependentsAfterDeleting) {
pendingDependencyWarningDisableTargets.value =
pendingDependencyWarningDisableTargets.value.filter((item) => item.enabled)
} else {
pendingDependencyWarningDisableTargets.value = []
}
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
await confirmDelete()
}
async function disablePendingDependencyWarningDependents() {
const items = pendingDependencyWarningDisableTargets.value.filter((item) => item.enabled)
pendingDependencyWarningDisableTargets.value = []
if (items.length === 0) return
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(items)
return
}
for (const item of items) {
const id = getItemId(item)
markChanging(id)
try {
await ctx.toggleEnabled(item)
} finally {
unmarkChanging(id)
}
}
}
async function confirmDelete() {
if (ctx.isBusy.value) return
const itemsToDelete = [...pendingDeletionItems.value]
@@ -451,7 +353,6 @@ async function confirmDelete() {
bulkWaiting.value = true
try {
await ctx.bulkDeleteItems(itemsToDelete)
await disablePendingDependencyWarningDependents()
} finally {
clearSelection()
isBulkOperating.value = false
@@ -468,7 +369,6 @@ async function confirmDelete() {
try {
await ctx.deleteItem(item)
removeFromSelection(id)
await disablePendingDependencyWarningDependents()
} finally {
unmarkChanging(id)
}
@@ -484,7 +384,6 @@ async function confirmDelete() {
},
{ onComplete: clearSelection },
)
await disablePendingDependencyWarningDependents()
}
async function handleToggleEnabledById(id: string, _value: boolean) {
@@ -980,17 +879,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDelete"
/>
<ContentDependencyWarningModal
ref="contentDependencyWarningModal"
:items="pendingDependencyWarningItems"
:dependents="pendingDependencyWarningDependents"
:item-type="ctx.contentTypeLabel.value"
:variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDependencyWarningDelete"
/>
<ConfirmBulkUpdateModal
v-if="hasBulkUpdateSupport"
ref="confirmBulkUpdateModal"
@@ -25,14 +25,6 @@ export interface ContentModpackData {
disabledText?: string
}
export interface ContentDependencyWarning {
items: ContentItem[]
dependents: Array<{
item: ContentItem
dependencies: ContentItem[]
}>
}
export interface ContentManagerContext {
// Data
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
@@ -63,9 +55,6 @@ export interface ContentManagerContext {
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
bulkEnableItems?: (items: ContentItem[]) => Promise<void>
bulkDisableItems?: (items: ContentItem[]) => Promise<void>
getDeleteDependencyWarning?: (
items: ContentItem[],
) => ContentDependencyWarning | null | Promise<ContentDependencyWarning | null>
// Update support (optional per-platform)
hasUpdateSupport: boolean
@@ -344,7 +344,7 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { ModrinthApiError } from '@modrinth/api-client'
import { getNodeWebSocketUrl, ModrinthApiError } from '@modrinth/api-client'
import {
BoxesIcon,
CheckIcon,
@@ -1299,7 +1299,7 @@ async function testNodeReachability(): Promise<boolean> {
const nodeInstance = serverData.value?.node?.instance
if (!nodeInstance) return false
const wsUrl = `wss://${nodeInstance}/pingtest`
const wsUrl = getNodeWebSocketUrl(`${nodeInstance}/pingtest`)
try {
return await new Promise((resolve) => {
-42
View File
@@ -377,48 +377,6 @@
"content.confirm-unlink.unlink-button": {
"defaultMessage": "Unlink"
},
"content.dependency-warning.admonition-header": {
"defaultMessage": "This content is required by other content"
},
"content.dependency-warning.affected-dependents-label": {
"defaultMessage": "Affected {count, plural, one {project} other {projects}}"
},
"content.dependency-warning.bulk-admonition-body": {
"defaultMessage": "Some selected projects are installed as dependencies. Deleting them may break your {context} or stop dependent content from loading correctly."
},
"content.dependency-warning.context.instance": {
"defaultMessage": "instance"
},
"content.dependency-warning.context.server": {
"defaultMessage": "server"
},
"content.dependency-warning.delete-anyway-button": {
"defaultMessage": "Delete anyway"
},
"content.dependency-warning.delete-many-anyway-button": {
"defaultMessage": "Delete {count, number} {itemType} anyway"
},
"content.dependency-warning.deleting-label": {
"defaultMessage": "Deleting"
},
"content.dependency-warning.disable-dependents-label": {
"defaultMessage": "Disable dependents after deleting"
},
"content.dependency-warning.effect-dependent-content": {
"defaultMessage": "Dependent content may fail to load or may disable itself"
},
"content.dependency-warning.effect-instance": {
"defaultMessage": "Your {context} may crash, refuse to start, or behave unexpectedly"
},
"content.dependency-warning.header": {
"defaultMessage": "Dependency warning"
},
"content.dependency-warning.single-admonition-body": {
"defaultMessage": "{project} is installed as a dependency. Deleting it may break your {context} or stop dependent content from loading correctly."
},
"content.dependency-warning.what-happens-label": {
"defaultMessage": "What happens?"
},
"content.diff-modal.added-count": {
"defaultMessage": "{count} added"
},
@@ -1,289 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentDependencyWarningModal from '../../layouts/shared/content-tab/components/modals/ContentDependencyWarningModal.vue'
import type { ContentCardTableItem } from '../../layouts/shared/content-tab/types'
const fabricApiItem: ContentCardTableItem = {
id: 'fabric-api',
project: {
id: 'P7dR8mSH',
slug: 'fabric-api',
title: 'Fabric API',
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
},
projectLink: '/project/fabric-api',
version: {
id: 'Lwa1Q6e4',
version_number: '0.141.3+1.21.6',
file_name: 'fabric-api-0.141.3+1.21.6.jar',
},
versionLink: '/project/fabric-api/version/Lwa1Q6e4',
owner: {
id: 'fabricmc',
name: 'FabricMC',
avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
type: 'organization',
link: '/organization/fabricmc',
},
enabled: true,
}
const sodiumItem: ContentCardTableItem = {
id: 'sodium',
project: {
id: 'AANobbMI',
slug: 'sodium',
title: 'Sodium',
icon_url:
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
},
projectLink: '/project/sodium',
version: {
id: 'sodium-version',
version_number: 'mc1.21.6-0.6.13-fabric',
file_name: 'sodium-fabric-0.6.13+mc1.21.6.jar',
},
versionLink: '/project/sodium/version/sodium-version',
owner: {
id: 'jellysquid3',
name: 'jellysquid3',
type: 'user',
link: '/user/jellysquid3',
},
enabled: true,
}
const irisItem: ContentCardTableItem = {
id: 'iris',
project: {
id: 'YL57xq9U',
slug: 'iris',
title: 'Iris Shaders',
icon_url: 'https://cdn.modrinth.com/data/YL57xq9U/icon.png',
},
projectLink: '/project/iris',
version: {
id: 'iris-version',
version_number: '1.8.12+1.21.6-fabric',
file_name: 'iris-fabric-1.8.12+mc1.21.6.jar',
},
versionLink: '/project/iris/version/iris-version',
owner: {
id: 'coderbot',
name: 'coderbot',
type: 'user',
link: '/user/coderbot',
},
enabled: true,
}
const lithiumItem: ContentCardTableItem = {
id: 'lithium',
project: {
id: 'gvQqBUqZ',
slug: 'lithium',
title: 'Lithium',
icon_url:
'https://cdn.modrinth.com/data/gvQqBUqZ/d6a1873d52b7d1c82b9a8d9b1889c9c1a29ae92d_96.webp',
},
projectLink: '/project/lithium',
version: {
id: 'lithium-version',
version_number: 'mc1.21.6-0.16.2-fabric',
file_name: 'lithium-fabric-0.16.2+mc1.21.6.jar',
},
versionLink: '/project/lithium/version/lithium-version',
owner: {
id: 'caffeinemc',
name: 'CaffeineMC',
type: 'organization',
link: '/organization/caffeinemc',
},
enabled: true,
}
const continuityItem: ContentCardTableItem = {
id: 'continuity',
project: {
id: '1IjD5062',
slug: 'continuity',
title: 'Continuity',
icon_url: 'https://cdn.modrinth.com/data/1IjD5062/icon.png',
},
projectLink: '/project/continuity',
version: {
id: 'continuity-version',
version_number: '3.0.1-beta.2+1.21.6',
file_name: 'continuity-3.0.1-beta.2+1.21.6.jar',
},
versionLink: '/project/continuity/version/continuity-version',
owner: {
id: 'pepper-bell',
name: 'Pepper_Bell',
type: 'user',
link: '/user/pepper-bell',
},
enabled: true,
}
const capeProviderItem: ContentCardTableItem = {
id: 'cape-provider',
project: {
id: 'cape-provider',
slug: 'cape-provider',
title: 'Cape Provider',
icon_url: null,
},
projectLink: '/project/cape-provider',
version: {
id: 'cape-provider-version',
version_number: '5.4.2',
file_name: 'cape-provider-5.4.2.jar',
},
versionLink: '/project/cape-provider/version/cape-provider-version',
owner: {
id: 'litetex',
name: 'litetex',
type: 'user',
link: '/user/litetex',
},
enabled: true,
}
const meta = {
title: 'Instances/ContentDependencyWarningModal',
component: ContentDependencyWarningModal,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ContentDependencyWarningModal>
export default meta
type Story = StoryObj<typeof meta>
export const InstanceDependency: Story = {
render: () => ({
components: { ButtonStyled, ContentDependencyWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
const deleted = ref(false)
function handleDelete() {
deleted.value = true
}
return {
modalRef,
deleted,
handleDelete,
fabricApiItem,
sodiumItem,
irisItem,
continuityItem,
lithiumItem,
capeProviderItem,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="orange">
<button @click="modalRef?.show()">Delete dependency</button>
</ButtonStyled>
<p v-if="deleted" class="m-0 text-sm text-secondary">Dependency deletion confirmed</p>
<ContentDependencyWarningModal
ref="modalRef"
:items="[fabricApiItem]"
item-type="project"
:dependents="[
{ item: sodiumItem, dependencies: [fabricApiItem] },
{ item: irisItem, dependencies: [fabricApiItem] },
{ item: continuityItem, dependencies: [fabricApiItem] },
{ item: lithiumItem, dependencies: [fabricApiItem] },
{ item: capeProviderItem, dependencies: [fabricApiItem] },
]"
@delete="handleDelete"
/>
</div>
`,
}),
}
export const ServerDependency: Story = {
render: () => ({
components: { ButtonStyled, ContentDependencyWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
const deleted = ref(false)
function handleDelete() {
deleted.value = true
}
return {
modalRef,
deleted,
handleDelete,
lithiumItem,
sodiumItem,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="orange">
<button @click="modalRef?.show()">Delete server dependency</button>
</ButtonStyled>
<p v-if="deleted" class="m-0 text-sm text-secondary">Server dependency deletion confirmed</p>
<ContentDependencyWarningModal
ref="modalRef"
:items="[lithiumItem]"
item-type="project"
:dependents="[{ item: sodiumItem, dependencies: [lithiumItem] }]"
variant="server"
@delete="handleDelete"
/>
</div>
`,
}),
}
export const BulkDependencies: Story = {
render: () => ({
components: { ButtonStyled, ContentDependencyWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
const deleted = ref(false)
function handleDelete() {
deleted.value = true
}
return {
modalRef,
deleted,
handleDelete,
fabricApiItem,
lithiumItem,
sodiumItem,
irisItem,
continuityItem,
capeProviderItem,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="orange">
<button @click="modalRef?.show()">Delete selected dependencies</button>
</ButtonStyled>
<p v-if="deleted" class="m-0 text-sm text-secondary">Bulk dependency deletion confirmed</p>
<ContentDependencyWarningModal
ref="modalRef"
:items="[fabricApiItem, lithiumItem]"
item-type="project"
:dependents="[
{ item: sodiumItem, dependencies: [fabricApiItem, lithiumItem] },
{ item: irisItem, dependencies: [fabricApiItem] },
{ item: continuityItem, dependencies: [fabricApiItem] },
{ item: capeProviderItem, dependencies: [lithiumItem] },
]"
@delete="handleDelete"
/>
</div>
`,
}),
}
+10 -10
View File
@@ -484,7 +484,7 @@ export function useSearch(
}
orGroups[field].push(val)
} else {
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
parts.push(`${field} = ${enquoteNonBools(val)}`)
}
}
}
@@ -492,15 +492,15 @@ export function useSearch(
for (const [field, values] of Object.entries(orGroups)) {
if (values.length === 1) {
const val = values[0]
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
parts.push(`${field} = ${enquoteNonBools(val)}`)
} else {
const quoted = values.map(formatSearchFilterValue).join(', ')
const quoted = values.map(enquoteNonBools).join(', ')
parts.push(`${field} IN [${quoted}]`)
}
}
for (const [field, values] of Object.entries(negativeByType)) {
const quoted = values.map(formatSearchFilterValue).join(', ')
const quoted = values.map(enquoteNonBools).join(', ')
parts.push(`${field} NOT IN [${quoted}]`)
}
@@ -514,11 +514,11 @@ export function useSearch(
for (const envGroup of getEnvironmentFilterGroups(client, server)) {
if (envGroup.length === 1) {
const [field, val] = envGroup[0].split(':')
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
parts.push(`${field} = ${enquoteNonBools(val)}`)
} else if (envGroup.length > 1) {
const conditions = envGroup.map((f) => {
const [field, val] = f.split(':')
return `${field} = ${formatSearchFilterValue(val)}`
return `${field} = ${enquoteNonBools(val)}`
})
parts.push(`(${conditions.join(' OR ')})`)
}
@@ -527,9 +527,9 @@ export function useSearch(
// Project types
const mappedProjectTypes = projectTypes.value.map(mapProjectTypeToSearch)
if (mappedProjectTypes.length === 1) {
parts.push(`project_types = ${formatSearchFilterValue(mappedProjectTypes[0])}`)
parts.push(`project_types = ${enquoteNonBools(mappedProjectTypes[0])}`)
} else if (mappedProjectTypes.length > 1) {
const quoted = mappedProjectTypes.map(formatSearchFilterValue).join(', ')
const quoted = mappedProjectTypes.map(enquoteNonBools).join(', ')
parts.push(`project_types IN [${quoted}]`)
}
@@ -792,11 +792,11 @@ function getEnvironmentFilterGroups(client: boolean, server: boolean): string[][
return groups
}
export function formatSearchFilterValue(value: string): string {
function enquoteNonBools(value: string): string {
if (value === 'true' || value === 'false') {
return value
}
return `\`${value}\``
return `"${value}"`
}
function getOptionValue(option: FilterOption, negative?: boolean): string {
+4 -5
View File
@@ -6,7 +6,6 @@ import { useRoute } from 'vue-router'
import { defineMessage, LOCALES, useVIntl } from '../composables/i18n'
import type { FilterType, FilterValue, SortType, Tags } from './search'
import { formatSearchFilterValue } from './search'
import { formatCategory, formatCategoryHeader } from './tag-messages'
export const SERVER_REGIONS = {
@@ -364,11 +363,11 @@ export function useServerSearch(opts: {
const included = matched.filter((f) => !f.negative)
const excluded = matched.filter((f) => f.negative)
if (included.length > 0) {
const values = included.map((f) => formatSearchFilterValue(f.option)).join(', ')
const values = included.map((f) => `"${f.option}"`).join(', ')
parts.push(`${field} IN [${values}]`)
}
if (excluded.length > 0) {
const values = excluded.map((f) => formatSearchFilterValue(f.option)).join(', ')
const values = excluded.map((f) => `"${f.option}"`).join(', ')
parts.push(`${field} NOT IN [${values}]`)
}
}
@@ -390,11 +389,11 @@ export function useServerSearch(opts: {
.map((filter) => filter.projectId)
if (includedProjectIds.length > 0) {
const values = includedProjectIds.map(formatSearchFilterValue).join(', ')
const values = includedProjectIds.map((projectId) => `"${projectId}"`).join(', ')
parts.push(`project_id IN [${values}]`)
}
if (excludedProjectIds.length > 0) {
const values = excludedProjectIds.map(formatSearchFilterValue).join(', ')
const values = excludedProjectIds.map((projectId) => `"${projectId}"`).join(', ')
parts.push(`project_id NOT IN [${values}]`)
}
+6 -1
View File
@@ -2,7 +2,12 @@ import preset from '@modrinth/tooling-config/tailwind/tailwind-preset.ts'
import type { Config } from 'tailwindcss'
const config: Config = {
content: ['./src/**/*.{js,vue,ts,mdx}', './.storybook/**/*.{ts,js}'],
content: [
'./src/components/**/*.{js,vue,ts}',
'./src/pages/**/*.{js,vue,ts}',
'./src/stories/**/*.{js,vue,ts,mdx}',
'./.storybook/**/*.{ts,js}',
],
presets: [preset],
}