Compare commits

...
28 changed files with 391 additions and 221 deletions
-7
View File
@@ -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' }}
-7
View File
@@ -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
-7
View File
@@ -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>
+20 -10
View File
@@ -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()
+7 -2
View File
@@ -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]
@@ -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))
+3 -4
View File
@@ -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))
}
}
+4 -7
View File
@@ -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))
}
}
+3 -4
View File
@@ -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))
}
}
+8 -7
View File
@@ -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
}
+3 -4
View File
@@ -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))
}
}
+3 -4
View File
@@ -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))
}
}
+3 -4
View File
@@ -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))
}
}
+2 -1
View File
@@ -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,
+14
View File
@@ -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',
+33
View File
@@ -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 })
}