mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bafc951af | ||
|
|
a00a7971d1 | ||
|
|
26e98b7155 | ||
|
|
77b06f679e | ||
|
|
50e9472dd9 | ||
|
|
b569354793 | ||
|
|
b667b3ae9e | ||
|
|
16de6182e4 | ||
|
|
364fed52dd | ||
|
|
0fe695721a | ||
|
|
692f22b749 | ||
|
|
ed7792097d |
@@ -76,13 +76,6 @@ jobs:
|
||||
env:
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
|
||||
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
needs: [skip-if-clean]
|
||||
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
|
||||
|
||||
@@ -78,13 +78,6 @@ jobs:
|
||||
GIT_HASH: ${{ github.sha }}
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
|
||||
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
|
||||
@@ -81,13 +81,6 @@ jobs:
|
||||
# blacksmith runner)
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
|
||||
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
|
||||
steps:
|
||||
|
||||
@@ -11,15 +11,10 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { readDir, stat } from '@tauri-apps/plugin-fs'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { PackageIcon } from '@/assets/icons'
|
||||
import {
|
||||
export_instance_mrpack,
|
||||
get_full_path,
|
||||
get_pack_export_candidates,
|
||||
} from '@/helpers/instance'
|
||||
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -57,7 +52,7 @@ defineExpose({
|
||||
show: () => {
|
||||
resetExportState()
|
||||
exportModal.value.show()
|
||||
initFiles()
|
||||
void initFiles().catch(handleError)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -69,34 +64,19 @@ const files = ref([])
|
||||
const selectedFilePaths = ref([])
|
||||
const fileTreeKey = ref(0)
|
||||
const filesLoadId = ref(0)
|
||||
const loadedDirectories = ref(new Set())
|
||||
|
||||
const initFiles = async () => {
|
||||
async function initFiles() {
|
||||
const loadId = ++filesLoadId.value
|
||||
const [filePaths, instanceRoot] = await Promise.all([
|
||||
get_pack_export_candidates(props.instance.id),
|
||||
get_full_path(props.instance.id),
|
||||
])
|
||||
const expandedFiles = await Promise.all(
|
||||
filePaths.map((path) => expandExportCandidate(instanceRoot, path)),
|
||||
)
|
||||
const exportCandidates = await get_pack_export_candidates(props.instance.id)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
files.value = expandedFiles.flat()
|
||||
files.value = exportCandidates
|
||||
selectedFilePaths.value = files.value
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.disabled &&
|
||||
(file.path.startsWith('mods') ||
|
||||
file.path.startsWith('datapacks') ||
|
||||
file.path.startsWith('resourcepacks') ||
|
||||
file.path.startsWith('shaderpacks') ||
|
||||
file.path.startsWith('config')),
|
||||
)
|
||||
.filter((file) => !file.disabled && file.defaultSelected)
|
||||
.map((file) => file.path)
|
||||
}
|
||||
|
||||
await initFiles()
|
||||
|
||||
const exportPack = async () => {
|
||||
const outputPath = await save({
|
||||
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
|
||||
@@ -128,77 +108,70 @@ function resetExportState() {
|
||||
files.value = []
|
||||
selectedFilePaths.value = []
|
||||
fileTreeKey.value += 1
|
||||
loadedDirectories.value = new Set()
|
||||
}
|
||||
|
||||
async function expandExportCandidate(instanceRoot, path) {
|
||||
try {
|
||||
const entries = await readDir(`${instanceRoot}/${path}`)
|
||||
if (entries.length === 0) {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return [
|
||||
{
|
||||
path,
|
||||
type: 'directory',
|
||||
disabled: true,
|
||||
modified: metadata.modified,
|
||||
count: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
async function loadExportDirectory(path) {
|
||||
const normalizedPath = normalizeExportPath(path)
|
||||
if (!normalizedPath) return
|
||||
|
||||
const children = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const childPath = `${path}/${entry.name}`
|
||||
if (entry.isDirectory) {
|
||||
return expandExportCandidate(instanceRoot, childPath)
|
||||
}
|
||||
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, childPath)
|
||||
return [
|
||||
{
|
||||
path: childPath,
|
||||
type: 'file',
|
||||
disabled: isExportCandidateDisabled(childPath),
|
||||
size: metadata.size,
|
||||
modified: metadata.modified,
|
||||
},
|
||||
]
|
||||
}),
|
||||
if (loadedDirectories.value.has(normalizedPath)) {
|
||||
replaceSelectedDirectoryWithLoadedChildren(
|
||||
normalizedPath,
|
||||
getLoadedDirectoryChildren(normalizedPath),
|
||||
)
|
||||
return children.flat()
|
||||
} catch {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return [
|
||||
{
|
||||
path,
|
||||
type: 'file',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
size: metadata.size,
|
||||
modified: metadata.modified,
|
||||
},
|
||||
]
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function getExportCandidateMetadata(instanceRoot, path) {
|
||||
const loadId = filesLoadId.value
|
||||
loadedDirectories.value.add(normalizedPath)
|
||||
|
||||
try {
|
||||
const metadata = await stat(`${instanceRoot}/${path}`)
|
||||
return {
|
||||
size: metadata.size,
|
||||
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
|
||||
}
|
||||
const childItems = await get_pack_export_candidates(props.instance.id, normalizedPath)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
appendExportItems(childItems)
|
||||
replaceSelectedDirectoryWithLoadedChildren(normalizedPath, childItems)
|
||||
} catch {
|
||||
return {}
|
||||
loadedDirectories.value.delete(normalizedPath)
|
||||
}
|
||||
}
|
||||
|
||||
function isExportCandidateDisabled(path) {
|
||||
return (
|
||||
path === 'profile.json' ||
|
||||
path.startsWith('modrinth_logs') ||
|
||||
path.startsWith('.fabric') ||
|
||||
path.startsWith('__MACOSX')
|
||||
)
|
||||
function appendExportItems(items) {
|
||||
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
|
||||
for (const item of items) {
|
||||
nextFiles.set(normalizeExportPath(item.path), item)
|
||||
}
|
||||
files.value = [...nextFiles.values()]
|
||||
}
|
||||
|
||||
function getLoadedDirectoryChildren(path) {
|
||||
const normalizedPath = normalizeExportPath(path)
|
||||
const prefix = `${normalizedPath}/`
|
||||
|
||||
return files.value.filter((item) => {
|
||||
const itemPath = normalizeExportPath(item.path)
|
||||
if (!itemPath.startsWith(prefix)) return false
|
||||
|
||||
return itemPath.slice(prefix.length).split('/').filter(Boolean).length === 1
|
||||
})
|
||||
}
|
||||
|
||||
function replaceSelectedDirectoryWithLoadedChildren(path, items) {
|
||||
const nextSelectedPaths = new Set(selectedFilePaths.value.map(normalizeExportPath))
|
||||
if (!nextSelectedPaths.delete(normalizeExportPath(path))) return
|
||||
|
||||
for (const item of items) {
|
||||
if (item && !item.disabled) {
|
||||
nextSelectedPaths.add(normalizeExportPath(item.path))
|
||||
}
|
||||
}
|
||||
|
||||
selectedFilePaths.value = [...nextSelectedPaths]
|
||||
}
|
||||
|
||||
function normalizeExportPath(path) {
|
||||
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -251,6 +224,7 @@ function isExportCandidateDisabled(path) {
|
||||
v-model="selectedFilePaths"
|
||||
class="min-w-0"
|
||||
:items="files"
|
||||
@navigate="loadExportDirectory"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
|
||||
@@ -297,16 +297,26 @@ export async function export_instance_mrpack(
|
||||
})
|
||||
}
|
||||
|
||||
// Given a folder path, populate an array of all the subfolders
|
||||
// Intended to be used for finding potential override folders
|
||||
// profile
|
||||
// -- mods
|
||||
// -- resourcepacks
|
||||
// -- file1
|
||||
// => [mods, resourcepacks]
|
||||
// allows selection for 'included_overrides' in export_instance_mrpack
|
||||
export async function get_pack_export_candidates(instanceId: string): Promise<string[]> {
|
||||
return await invoke('plugin:instance|instance_get_pack_export_candidates', { instanceId })
|
||||
export type PackExportCandidate = {
|
||||
path: string
|
||||
type: 'directory' | 'file'
|
||||
size?: number
|
||||
modified?: number
|
||||
count?: number
|
||||
disabled: boolean
|
||||
defaultSelected: boolean
|
||||
}
|
||||
|
||||
// Given a folder path, populate an array of exportable direct children.
|
||||
// Allows selection for 'included_overrides' in export_instance_mrpack.
|
||||
export async function get_pack_export_candidates(
|
||||
instanceId: string,
|
||||
parent?: string,
|
||||
): Promise<PackExportCandidate[]> {
|
||||
return await invoke('plugin:instance|instance_get_pack_export_candidates', {
|
||||
instanceId,
|
||||
parent: parent ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
// Run Minecraft using an instance
|
||||
|
||||
@@ -676,7 +676,7 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
|
||||
break
|
||||
case 'copy_path': {
|
||||
if (instance.value) {
|
||||
const fullPath = await get_full_path(instance.value?.path)
|
||||
const fullPath = await get_full_path(instance.value.id)
|
||||
await navigator.clipboard.writeText(fullPath)
|
||||
}
|
||||
break
|
||||
|
||||
@@ -1443,7 +1443,7 @@ onMounted(() => {
|
||||
props.instance &&
|
||||
event.instance_id === props.instance.id &&
|
||||
event.event === 'synced' &&
|
||||
props.instance.install_stage !== 'pack_installing' &&
|
||||
props.instance.install_stage === 'installed' &&
|
||||
!isBulkOperating.value
|
||||
) {
|
||||
await initProjects()
|
||||
|
||||
@@ -699,8 +699,13 @@ pub async fn instance_export_mrpack(
|
||||
#[tauri::command]
|
||||
pub async fn instance_get_pack_export_candidates(
|
||||
instance_id: &str,
|
||||
) -> Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
|
||||
Ok(theseus::instance::get_pack_export_candidates(instance_id).await?)
|
||||
parent: Option<SafeRelativeUtf8UnixPathBuf>,
|
||||
) -> Result<Vec<theseus::instance::PackExportCandidate>> {
|
||||
Ok(theseus::instance::get_pack_export_candidates_for_parent(
|
||||
instance_id,
|
||||
parent,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+1
-1
@@ -566,7 +566,7 @@ export function buildChartDatasets(
|
||||
? isNoDependentAnalyticsBreakdownValue(dependentProjectId)
|
||||
? formatMessage(analyticsMessages.noDependentTooltip)
|
||||
: isUnknownAnalyticsBreakdownValue(dependentProjectId)
|
||||
? formatMessage(analyticsMessages.unknown)
|
||||
? formatMessage(analyticsMessages.unknownDependentTooltip)
|
||||
: formatDependentProjectDatasetTooltip(
|
||||
versionName,
|
||||
dependentProjectName,
|
||||
|
||||
@@ -92,6 +92,11 @@ export const analyticsMessages = defineMessages({
|
||||
id: 'analytics.value.no-dependent-tooltip',
|
||||
defaultMessage: 'Downloaded for reasons other than being a dependency',
|
||||
},
|
||||
unknownDependentTooltip: {
|
||||
id: 'analytics.value.unknown-dependent-tooltip',
|
||||
defaultMessage:
|
||||
"There's no metadata to determine which dependent project this download attributes to.",
|
||||
},
|
||||
other: {
|
||||
id: 'analytics.value.other',
|
||||
defaultMessage: 'Other',
|
||||
|
||||
@@ -515,7 +515,7 @@ function getDependentProjectTooltip(row: AnalyticsTableRow) {
|
||||
return formatMessage(analyticsMessages.noDependentTooltip)
|
||||
}
|
||||
if (isUnknownAnalyticsBreakdownValue(row.breakdownValues.dependent_project_download)) {
|
||||
return formatMessage(analyticsMessages.unknown)
|
||||
return formatMessage(analyticsMessages.unknownDependentTooltip)
|
||||
}
|
||||
|
||||
const dependencyProjectIds = new Set(row.dependentOnProjectIds)
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
:options="[userOption, ...ownerOptions]"
|
||||
searchable
|
||||
:disabled="hasHitLimit"
|
||||
select-search-text-on-focus
|
||||
show-icon-in-selected
|
||||
/>
|
||||
<span>{{ formatMessage(messages.ownerDescription) }}</span>
|
||||
@@ -129,7 +130,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { OrganizationIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
@@ -362,7 +363,13 @@ async function fetchOrganizations() {
|
||||
}),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
: markRaw(
|
||||
defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () => h(OrganizationIcon, { class: 'size-5' }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}))
|
||||
if (props.organizationId) owner.value = props.organizationId
|
||||
} catch (err) {
|
||||
@@ -395,6 +402,7 @@ async function createProject() {
|
||||
server_side: 'required',
|
||||
license_id: 'LicenseRef-Unknown',
|
||||
is_draft: true,
|
||||
organization_id: owner.value !== 'self' ? owner.value : undefined,
|
||||
}
|
||||
|
||||
formData.append('data', JSON.stringify(projectData))
|
||||
|
||||
@@ -518,6 +518,9 @@
|
||||
"analytics.value.unknown": {
|
||||
"message": "Unknown"
|
||||
},
|
||||
"analytics.value.unknown-dependent-tooltip": {
|
||||
"message": "There's no metadata to determine which dependent project this download attributes to."
|
||||
},
|
||||
"analytics.value.unmonetized": {
|
||||
"message": "Unmonetized"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashSet;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -388,7 +388,7 @@ pub async fn pride_26(
|
||||
Ok(campaign_info) => Ok(web::Json(campaign_info)),
|
||||
Err(error) => {
|
||||
if let Some(cached) = cached {
|
||||
warn!(
|
||||
debug!(
|
||||
"Failed to refresh campaign info from Tiltify: {error:?}"
|
||||
);
|
||||
Ok(web::Json(cached))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
@@ -29,8 +30,6 @@ impl super::Validator for FabricValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use std::io::Cursor;
|
||||
@@ -36,9 +37,7 @@ impl super::Validator for ForgeValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +73,6 @@ impl super::Validator for LegacyForgeValidator {
|
||||
));
|
||||
};
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
@@ -29,8 +30,6 @@ impl super::Validator for LiteLoaderValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,9 +326,10 @@ fn game_version_supported(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_out_packs(
|
||||
archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
|
||||
) -> Result<ValidationResult, ValidationError> {
|
||||
#[must_use]
|
||||
pub fn validate_pack_formats(
|
||||
archive: &mut ZipArchive<Cursor<Bytes>>,
|
||||
) -> ValidationResult {
|
||||
if (archive.by_name("modlist.html").is_ok()
|
||||
&& archive.by_name("manifest.json").is_ok())
|
||||
|| archive
|
||||
@@ -338,10 +339,10 @@ pub fn filter_out_packs(
|
||||
.file_names()
|
||||
.any(|x| x.starts_with("override/mods/") && x.ends_with(".jar"))
|
||||
{
|
||||
return Ok(ValidationResult::Warning(
|
||||
"Invalid modpack file. You must upload a valid .MRPACK file.",
|
||||
));
|
||||
return ValidationResult::Warning(
|
||||
"Invalid modpack file. Modpacks must be uploaded in the .mrpack format, not as a ZIP file.",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
ValidationResult::Pass
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
@@ -33,8 +34,6 @@ impl super::Validator for NeoForgeValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use std::io::Cursor;
|
||||
@@ -34,8 +35,6 @@ impl super::Validator for QuiltValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
@@ -29,8 +30,6 @@ impl super::Validator for RiftValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ pub use self::content::{
|
||||
list_content_sets, sync_content_files,
|
||||
};
|
||||
pub use self::export_mrpack::{
|
||||
create_mrpack_json, export_mrpack, get_pack_export_candidates,
|
||||
PackExportCandidate, create_mrpack_json, export_mrpack,
|
||||
get_pack_export_candidates, get_pack_export_candidates_for_parent,
|
||||
};
|
||||
pub use self::get::{get, get_many, list};
|
||||
pub use self::install::get_optimal_jre_key;
|
||||
|
||||
@@ -13,12 +13,56 @@ use crate::util::io::{self, IOError};
|
||||
use async_zip::tokio::write::ZipFileWriter;
|
||||
use async_zip::{Compression, ZipEntryBuilder};
|
||||
use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter::FromIterator;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
const DEFAULT_SELECTED_EXPORT_PATH_PREFIXES: &[&str] =
|
||||
&["mods", "datapacks", "resourcepacks", "shaderpacks", "config"];
|
||||
|
||||
const NEVER_EXPORTABLE_PATH_PREFIXES: &[&str] = &[
|
||||
"profile.json",
|
||||
"modrinth_logs",
|
||||
"mods/.connector",
|
||||
".sable/natives",
|
||||
"local/crash_assistant",
|
||||
"mods/mcef-libraries",
|
||||
"mods/mcef-cache",
|
||||
"config/super_resolution/libraries",
|
||||
"config/Veinminer/update",
|
||||
"config/epicfight/native",
|
||||
"essential",
|
||||
".mixin.out",
|
||||
".fabric",
|
||||
"__MACOSX",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackExportCandidate {
|
||||
pub path: SafeRelativeUtf8UnixPathBuf,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: PackExportCandidateType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modified: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count: Option<usize>,
|
||||
pub disabled: bool,
|
||||
pub default_selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PackExportCandidateType {
|
||||
Directory,
|
||||
File,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn export_mrpack(
|
||||
instance_id: &str,
|
||||
@@ -38,13 +82,9 @@ pub async fn export_mrpack(
|
||||
})?;
|
||||
let included_export_candidates = included_export_candidates
|
||||
.into_iter()
|
||||
.filter(|x| {
|
||||
if let Some(f) = PathBuf::from(x).file_name()
|
||||
&& f.to_string_lossy().starts_with(".DS_Store")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
.filter_map(|path| SafeRelativeUtf8UnixPathBuf::try_from(path).ok())
|
||||
.filter(|path| {
|
||||
!path.as_str().is_empty() && is_path_exportable(path)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -56,12 +96,13 @@ pub async fn export_mrpack(
|
||||
let version_id = version_id.unwrap_or("1.0.0".to_string());
|
||||
let mut packfile =
|
||||
create_mrpack_json(&metadata, version_id, description).await?;
|
||||
let included_candidates_set = HashSet::<_>::from_iter(
|
||||
included_export_candidates.iter().map(|x| x.as_str()),
|
||||
);
|
||||
packfile
|
||||
.files
|
||||
.retain(|f| included_candidates_set.contains(f.path.as_str()));
|
||||
packfile.files.retain(|f| {
|
||||
is_path_exportable(&f.path)
|
||||
&& is_export_candidate_included(
|
||||
&f.path,
|
||||
&included_export_candidates,
|
||||
)
|
||||
});
|
||||
|
||||
let mut path_list = Vec::new();
|
||||
add_all_recursive_folder_paths(&instance_base_path, &mut path_list).await?;
|
||||
@@ -80,9 +121,11 @@ pub async fn export_mrpack(
|
||||
let relative_path = pack_get_relative_path(&instance_base_path, &path)?;
|
||||
|
||||
if packfile.files.iter().any(|f| f.path == relative_path)
|
||||
|| !included_candidates_set
|
||||
.iter()
|
||||
.any(|x| relative_path.starts_with(&**x))
|
||||
|| !is_path_exportable(&relative_path)
|
||||
|| !is_export_candidate_included(
|
||||
&relative_path,
|
||||
&included_export_candidates,
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -112,38 +155,123 @@ pub async fn export_mrpack(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_export_candidate_included(
|
||||
path: &SafeRelativeUtf8UnixPathBuf,
|
||||
included_export_candidates: &[SafeRelativeUtf8UnixPathBuf],
|
||||
) -> bool {
|
||||
let path = path.as_str();
|
||||
|
||||
included_export_candidates.iter().any(|candidate| {
|
||||
let candidate = candidate.as_str();
|
||||
|
||||
path == candidate
|
||||
|| path
|
||||
.strip_prefix(candidate)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_path_exportable(relative_path: &SafeRelativeUtf8UnixPathBuf) -> bool {
|
||||
let path = relative_path.as_str();
|
||||
|
||||
if path.ends_with(".DS_Store") {
|
||||
return false;
|
||||
}
|
||||
|
||||
!NEVER_EXPORTABLE_PATH_PREFIXES.iter().any(|prefix| {
|
||||
path == *prefix
|
||||
|| path
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_pack_export_candidates(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
|
||||
) -> crate::Result<Vec<PackExportCandidate>> {
|
||||
get_pack_export_candidates_for_parent(instance_id, None).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_pack_export_candidates_for_parent(
|
||||
instance_id: &str,
|
||||
parent: Option<SafeRelativeUtf8UnixPathBuf>,
|
||||
) -> crate::Result<Vec<PackExportCandidate>> {
|
||||
let mut path_list = Vec::new();
|
||||
let instance_base_dir = get_full_path(instance_id).await?;
|
||||
let mut read_dir = io::read_dir(&instance_base_dir).await?;
|
||||
let parent_dir = if let Some(parent) = parent {
|
||||
if parent.as_str().is_empty() || !is_path_exportable(&parent) {
|
||||
return Ok(path_list);
|
||||
}
|
||||
|
||||
instance_base_dir.join(parent.as_str())
|
||||
} else {
|
||||
instance_base_dir.clone()
|
||||
};
|
||||
let mut read_dir = io::read_dir(&parent_dir).await?;
|
||||
while let Some(entry) = read_dir
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
|
||||
.map_err(|e| IOError::with_path(e, &parent_dir))?
|
||||
{
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let mut read_dir = io::read_dir(&path).await?;
|
||||
while let Some(entry) = read_dir
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
|
||||
{
|
||||
path_list.push(pack_get_relative_path(
|
||||
&instance_base_dir,
|
||||
&entry.path(),
|
||||
)?);
|
||||
}
|
||||
} else {
|
||||
path_list.push(pack_get_relative_path(&instance_base_dir, &path)?);
|
||||
if let Some(candidate) =
|
||||
build_pack_export_candidate(&instance_base_dir, &path).await?
|
||||
{
|
||||
path_list.push(candidate);
|
||||
}
|
||||
}
|
||||
Ok(path_list)
|
||||
}
|
||||
|
||||
async fn build_pack_export_candidate(
|
||||
instance_base_dir: &PathBuf,
|
||||
path: &PathBuf,
|
||||
) -> crate::Result<Option<PackExportCandidate>> {
|
||||
let relative_path = pack_get_relative_path(instance_base_dir, path)?;
|
||||
if !is_path_exportable(&relative_path) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let metadata = io::metadata(path).await?;
|
||||
let kind = if metadata.is_dir() {
|
||||
PackExportCandidateType::Directory
|
||||
} else {
|
||||
PackExportCandidateType::File
|
||||
};
|
||||
let size = metadata.is_file().then_some(metadata.len());
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs());
|
||||
let default_selected = is_default_selected_export_candidate(&relative_path);
|
||||
|
||||
Ok(Some(PackExportCandidate {
|
||||
path: relative_path,
|
||||
kind,
|
||||
size,
|
||||
modified,
|
||||
count: None,
|
||||
disabled: false,
|
||||
default_selected,
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_default_selected_export_candidate(
|
||||
relative_path: &SafeRelativeUtf8UnixPathBuf,
|
||||
) -> bool {
|
||||
let path = relative_path.as_str();
|
||||
|
||||
DEFAULT_SELECTED_EXPORT_PATH_PREFIXES.iter().any(|prefix| {
|
||||
path == *prefix
|
||||
|| path
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
fn pack_get_relative_path(
|
||||
instance_path: &PathBuf,
|
||||
path: &PathBuf,
|
||||
|
||||
@@ -209,29 +209,18 @@ pub(crate) async fn list_content(
|
||||
.await?;
|
||||
let imported_modpack_scope = is_imported_modpack_scope(&link);
|
||||
let linked_modpack_source_kind = linked_modpack_source_kind(&link);
|
||||
let mut failed_modpack_identifier_lookup = false;
|
||||
let modpack_ids = if imported_modpack_scope {
|
||||
None
|
||||
} else {
|
||||
match linked_modpack_ids(&link) {
|
||||
Some((_, version_id)) => match get_modpack_identifiers(
|
||||
&version_id,
|
||||
&resolved.content_set,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => Some(ids),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch modpack identifiers: {}",
|
||||
err
|
||||
);
|
||||
failed_modpack_identifier_lookup = true;
|
||||
None
|
||||
}
|
||||
},
|
||||
Some((_, version_id)) => {
|
||||
get_cached_modpack_identifiers(
|
||||
&version_id,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
@@ -243,10 +232,9 @@ pub(crate) async fn list_content(
|
||||
}
|
||||
} else if let Some(ids) = modpack_ids.as_ref() {
|
||||
ContentFilter::ExcludeModpack(ids)
|
||||
} else if failed_modpack_identifier_lookup {
|
||||
} else if let Some(source_kind) = linked_modpack_source_kind {
|
||||
ContentFilter::ExcludeSourceKind {
|
||||
source_kind: linked_modpack_source_kind
|
||||
.unwrap_or(ContentSourceKind::ModrinthModpack),
|
||||
source_kind,
|
||||
exclude_untracked: true,
|
||||
}
|
||||
} else {
|
||||
@@ -1182,6 +1170,28 @@ impl ModpackIdentifiers {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_cached_modpack_identifiers(
|
||||
version_id: &str,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Option<ModpackIdentifiers>> {
|
||||
let Some(cached) =
|
||||
CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if cached.project_ids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(ModpackIdentifiers {
|
||||
hashes: cached.file_hashes.into_iter().collect(),
|
||||
project_ids: cached.project_ids.into_iter().collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_modpack_identifiers(
|
||||
version_id: &str,
|
||||
content_set: &ContentSet,
|
||||
|
||||
@@ -10,6 +10,20 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-06-29T23:56:37+00:00`,
|
||||
product: 'app',
|
||||
version: '0.15.4',
|
||||
body: `## Fixed
|
||||
- Fixes another issue causing the app to freeze up when going into an instance page.`,
|
||||
},
|
||||
{
|
||||
date: `2026-06-29T22:52:04+00:00`,
|
||||
product: 'app',
|
||||
version: '0.15.3',
|
||||
body: `## Fixed
|
||||
- Fixed hanging on larger legacy instances when visiting the Instance page.`,
|
||||
},
|
||||
{
|
||||
date: `2026-06-29T20:44:35+00:00`,
|
||||
product: 'web',
|
||||
|
||||
@@ -202,6 +202,39 @@ export const coreNags: Nag[] = [
|
||||
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-license',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'add-custom-license-details',
|
||||
title: defineMessage({
|
||||
id: 'nags.add-license-details.title',
|
||||
defaultMessage: 'Add license details',
|
||||
}),
|
||||
description: (context: NagContext) => {
|
||||
const { formatMessage } = useVIntl()
|
||||
return formatMessage(
|
||||
defineMessage({
|
||||
id: 'nags.add-license-details.description',
|
||||
defaultMessage: 'Add a valid URL and name or SPDX identifier for your custom license.',
|
||||
}),
|
||||
{
|
||||
type: formatProjectTypeSentence(formatMessage, context.project.project_type),
|
||||
},
|
||||
)
|
||||
},
|
||||
status: 'required',
|
||||
shouldShow: (context: NagContext) =>
|
||||
context.project.license.id === 'LicenseRef-' ||
|
||||
((!context.project.license.url || context.project.license.url === '') &&
|
||||
!context.projectV3?.minecraft_server &&
|
||||
context.project.license.id !== 'LicenseRef-Unknown'),
|
||||
link: {
|
||||
path: 'settings/license',
|
||||
title: defineMessage({
|
||||
id: 'nags.settings.license.title',
|
||||
defaultMessage: 'Visit license settings',
|
||||
}),
|
||||
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-license',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'review-permissions',
|
||||
title: defineMessage({
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
"nags.add-java-address.title": {
|
||||
"defaultMessage": "Add a Java address"
|
||||
},
|
||||
"nags.add-license-details.description": {
|
||||
"defaultMessage": "Add a valid URL and name or SPDX identifier for your custom license."
|
||||
},
|
||||
"nags.add-license-details.title": {
|
||||
"defaultMessage": "Add license details"
|
||||
},
|
||||
"nags.add-links-server.description": {
|
||||
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as a website, store, or a Discord invite."
|
||||
},
|
||||
|
||||
@@ -505,7 +505,7 @@ function buildDirectoryEntry(
|
||||
indeterminate: !selected && selectedCount > 0 && selectedCount < descendants.length,
|
||||
disabled: item?.disabled ?? descendants.length === 0,
|
||||
modified: item?.modified ?? getLatestModified(descendants),
|
||||
count: item?.count ?? getFolderChildCount(path),
|
||||
count: item?.count ?? (item ? undefined : getFolderChildCount(path)),
|
||||
item,
|
||||
}
|
||||
}
|
||||
@@ -590,6 +590,7 @@ function toggleAllVisible(selected: boolean) {
|
||||
|
||||
function formatSize(entry: FileTreeSelectEntry) {
|
||||
if (entry.type === 'directory') {
|
||||
if (entry.count === undefined) return ''
|
||||
return formatMessage(messages.itemCount, { count: entry.count ?? 0 })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user