diff --git a/README.md b/README.md index f7c3730a79..7b4f90ef20 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@    - +   ## Modrinth Monorepo -Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors! +Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors! If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app). diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 9b8fdec3b9..fea079df51 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -410,12 +410,27 @@ "app.project.install-button.already-installed": { "message": "This project is already installed" }, + "app.project.install-button.switch-version": { + "message": "Switch version" + }, "app.project.install-context.back-to-browse": { "message": "Back to discover" }, "app.project.install-context.install-content-to-instance": { "message": "Install content to instance" }, + "app.project.version.all-versions": { + "message": "All versions" + }, + "app.project.version.download-in-browser": { + "message": "Download in browser" + }, + "app.project.version.installing": { + "message": "Installing" + }, + "app.project.versions.already-installed": { + "message": "Already installed" + }, "app.settings.developer-mode-enabled": { "message": "Developer mode enabled." }, diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index 0eeede917a..253d8430eb 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -49,6 +49,7 @@ import { get_installed_project_ids as getInstalledProjectIds, } from '@/helpers/instance' import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata' +import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' import { get_instance_worlds } from '@/helpers/worlds' import { injectContentInstall } from '@/providers/content-install' @@ -1041,10 +1042,24 @@ function getProjectBrowseQuery() { } } +const advancedFiltersCollapsed = computed({ + get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'), + set: (value) => { + themeStore.featureFlags['advanced_filters_collapsed'] = value + getSettings() + .then((settings) => { + settings.feature_flags['advanced_filters_collapsed'] = value + return setSettings(settings) + }) + .catch(handleError) + }, +}) + provideBrowseManager({ tags, projectType, ...searchState, + advancedFiltersCollapsed, getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({ path: `/project/${result.project_id ?? result.slug}`, query: getProjectBrowseQuery(), diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index 6bcff92a0e..485d3065de 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -1351,7 +1351,9 @@ provideContentManager({ title: item.file_name.replace('.disabled', ''), icon_url: null, }, - projectLink: item.project?.id ? { path: `/project/${item.project.id}` } : undefined, + projectLink: item.project?.id + ? { path: `/project/${item.project.id}`, query: { i: props.instance.id } } + : undefined, version: item.version ?? { id: item.file_name, version_number: formatMessage(commonMessages.unknownLabel), @@ -1361,6 +1363,7 @@ provideContentManager({ item.project?.id && item.version?.id ? { path: `/project/${item.project.id}/version/${item.version.id}`, + query: { i: props.instance.id }, } : undefined, owner: item.owner diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index d9230361ef..e14dcc0f33 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -116,13 +116,27 @@ aria-label="More options" > - Open in browser + + {{ formatMessage(commonMessages.openInBrowserButton) }} + Report - + + + + {{ formatMessage(commonMessages.installedLabel) }} + + + + + + {{ formatMessage(messages.switchVersion) }} + + + - Open in browser + + {{ formatMessage(commonMessages.openInBrowserButton) }} + Follow Save Report @@ -294,6 +310,7 @@ import relativeTime from 'dayjs/plugin/relativeTime' import { computed, onUnmounted, ref, shallowRef, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { SwapIcon } from '@/assets/icons/index.js' import ContextMenu from '@/components/ui/ContextMenu.vue' import InstanceIndicator from '@/components/ui/InstanceIndicator.vue' import { @@ -349,6 +366,10 @@ const messages = defineMessages({ id: 'app.project.install-button.already-installed', defaultMessage: 'This project is already installed', }, + switchVersion: { + id: 'app.project.install-button.switch-version', + defaultMessage: 'Switch version', + }, }) const { installingServerProjects, playServerProject, showAddServerToInstanceModal } = @@ -520,6 +541,13 @@ const installButtonTooltip = computed(() => { return null }) +const showSwitchVersion = computed(() => !!instance.value && installed.value) +const onVersionsPage = computed(() => route.name === 'Versions') + +function goToVersions() { + router.push(versionsHref.value) +} + const [allLoaders, allGameVersions] = await Promise.all([ get_loaders().catch(handleError).then(ref), get_game_versions().catch(handleError).then(ref), diff --git a/apps/app-frontend/src/pages/project/Version.vue b/apps/app-frontend/src/pages/project/Version.vue index 8a37f765e2..6ea656c4ed 100644 --- a/apps/app-frontend/src/pages/project/Version.vue +++ b/apps/app-frontend/src/pages/project/Version.vue @@ -1,259 +1,152 @@ - - - - - {{ version.name }} - - + + + {{ formatMessage(messages.allVersions) }} + + + install(version.id)" + @click="() => version && install(version.id)" > {{ installing - ? 'Installing...' + ? formatMessage(messages.installing) : installed && installedVersion === version.id - ? 'Installed' - : 'Install' + ? formatMessage(commonMessages.installedLabel) + : installed + ? formatMessage(commonMessages.switchToVersionButton) + : formatMessage(commonMessages.installButton) }} - - - - Report - - - - + - Modrinth website - + + + + {{ formatMessage(commonMessages.openInBrowserButton) }} + + + {{ formatMessage(commonMessages.reportButton) }} + + + + + + + + + {{ formatMessage(messages.downloadInBrowser) }} - - - - - - Changelog - - - - Files - - - - - - {{ file.filename }} - - ({{ formatBytes(file.size) }}) - Primary - - - - install(version.id)"> - - - {{ installed ? 'Installed' : 'Install' }} - - - - - - Dependencies - - - - - {{ dependency.title }} - {{ dependency.subtitle }} - - - - - - {{ dependency.title }} - {{ dependency.subtitle }} - - - - - - - Metadata - - - Release Channel - - - - Version Number - {{ version.version_number }} - - - Loaders - {{ - version.loaders - .map((loader) => loader.charAt(0).toUpperCase() + loader.slice(1)) - .join(', ') - }} - - - Game Versions - {{ version.game_versions.join(', ') }} - - - Downloads - {{ version.downloads }} - - - Publication Date - - {{ formatDateTime(version.date_published) }} - - - - Author - - - - - {{ author.user.username }} - - - {{ author.role }} - - - - - Version ID - - - - - + + - - - diff --git a/apps/app-frontend/src/pages/project/Versions.vue b/apps/app-frontend/src/pages/project/Versions.vue index 584801fff5..7d7438cf12 100644 --- a/apps/app-frontend/src/pages/project/Versions.vue +++ b/apps/app-frontend/src/pages/project/Versions.vue @@ -9,10 +9,18 @@ :version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)" > - + install(version.id)" @@ -45,11 +53,13 @@ Add to another instance - Open in browser + + {{ formatMessage(commonMessages.openInBrowserButton) }} + @@ -65,9 +75,12 @@ import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets' import { ButtonStyled, + commonMessages, + defineMessages, injectNotificationManager, OverflowMenu, ProjectPageVersions, + useVIntl, } from '@modrinth/ui' import { ref } from 'vue' import { useRoute } from 'vue-router' @@ -76,8 +89,16 @@ import { SwapIcon } from '@/assets/icons/index.js' import { get_game_versions, get_loaders } from '@/helpers/tags.js' import { useTheming } from '@/store/theme.ts' +const { formatMessage } = useVIntl() const themeStore = useTheming() +const messages = defineMessages({ + alreadyInstalled: { + id: 'app.project.versions.already-installed', + defaultMessage: 'Already installed', + }, +}) + defineProps({ project: { type: Object, diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts index 09d7f5189b..7c0d1624e3 100644 --- a/apps/app-frontend/src/store/theme.ts +++ b/apps/app-frontend/src/store/theme.ts @@ -16,6 +16,7 @@ export const DEFAULT_FEATURE_FLAGS = { pride_fundraiser: true, i18n_debug: false, show_instance_play_time: true, + advanced_filters_collapsed: true, } export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const diff --git a/apps/app/build.rs b/apps/app/build.rs index cda15ffcdf..3bc241475d 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -276,6 +276,8 @@ fn main() { "hide_ads_window", "scroll_ads_window", "show_ads_window", + "show_ads_consent_overlay", + "hide_ads_consent_overlay", "record_ads_click", "open_link", "get_ads_personalization", diff --git a/apps/app/src/api/ads-init.js b/apps/app/src/api/ads-init.js index bd4582729c..b8f95bdd46 100644 --- a/apps/app/src/api/ads-init.js +++ b/apps/app/src/api/ads-init.js @@ -1,14 +1,16 @@ +const MODRINTH_ORIGIN = 'https://modrinth.com' + document.addEventListener( 'click', function (e) { - window.top.postMessage({ modrinthAdClick: true }, 'https://modrinth.com') + window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN) let target = e.target while (target != null) { if (target.matches('a')) { e.preventDefault() if (target.href) { - window.top.postMessage({ modrinthOpenUrl: target.href }, 'https://modrinth.com') + window.top.postMessage({ modrinthOpenUrl: target.href }, MODRINTH_ORIGIN) } break } @@ -19,7 +21,93 @@ document.addEventListener( ) window.open = (url, target, features) => { - window.top.postMessage({ modrinthOpenUrl: url }, 'https://modrinth.com') + window.top.postMessage({ modrinthOpenUrl: url }, MODRINTH_ORIGIN) +} + +let modrinthAdsConsentOverlayShown = false +let modrinthTcfListenerInstalled = false +let modrinthTcfListenerAttempts = 0 + +function installAdsConsentOverlayStyle() { + if (document.getElementById('modrinth-ads-consent-overlay-style')) { + return + } + const style = document.createElement('style') + style.id = 'modrinth-ads-consent-overlay-style' + style.textContent = ` + html.modrinth-ads-consent-overlay #modrinth-rail-1 { + visibility: hidden !important; + } + ` + document.documentElement.appendChild(style) +} + +function getTauriInvoke() { + return window.__TAURI__?.core?.invoke ?? window.__TAURI_INTERNALS__?.invoke +} + +function invokeAdsConsentOverlayCommand(shown) { + const invoke = getTauriInvoke() + + if (typeof invoke !== 'function') { + return + } + + const command = shown ? 'show_ads_consent_overlay' : 'hide_ads_consent_overlay' + const args = shown ? {} : { dpr: window.devicePixelRatio } + + invoke(`plugin:ads|${command}`, args).catch(() => {}) +} + +function setAdsConsentOverlay(shown) { + if (modrinthAdsConsentOverlayShown === shown) return + + modrinthAdsConsentOverlayShown = shown + installAdsConsentOverlayStyle() + document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown) + + if (window.top === window) { + invokeAdsConsentOverlayCommand(shown) + } else { + window.top.postMessage({ modrinthAdsConsentOverlay: shown }, MODRINTH_ORIGIN) + } +} + +if (window.top === window) { + window.addEventListener('message', (event) => { + if ( + event.origin === MODRINTH_ORIGIN && + typeof event.data?.modrinthAdsConsentOverlay === 'boolean' + ) { + setAdsConsentOverlay(event.data.modrinthAdsConsentOverlay) + } + }) +} + +function handleTcfConsentEvent(tcData, success) { + if (!success || !tcData) return + + if (tcData.eventStatus === 'cmpuishown') { + setAdsConsentOverlay(true) + } else if (tcData.eventStatus === 'useractioncomplete' || tcData.eventStatus === 'tcloaded') { + setAdsConsentOverlay(false) + } +} + +// polling to install listener on tcf api +function installTcfConsentListener() { + if (modrinthTcfListenerInstalled) return + + if (typeof window.__tcfapi === 'function') { + modrinthTcfListenerInstalled = true + window.__tcfapi('addEventListener', 2, handleTcfConsentEvent) + return + } + + if (modrinthTcfListenerAttempts < 60) { + modrinthTcfListenerAttempts += 1 + setTimeout(installTcfConsentListener, 500) + } } function muteAudioContext() { @@ -100,7 +188,10 @@ function muteVideos() { document.addEventListener('DOMContentLoaded', () => { muteVideos() muteAudioContext() + installTcfConsentListener() const observer = new MutationObserver(muteVideos) observer.observe(document.body, { childList: true, subtree: true }) }) + +installTcfConsentListener() diff --git a/apps/app/src/api/ads.rs b/apps/app/src/api/ads.rs index 3e23c5275e..ba06283d0b 100644 --- a/apps/app/src/api/ads.rs +++ b/apps/app/src/api/ads.rs @@ -11,12 +11,14 @@ use tokio::sync::RwLock; pub struct AdsState { pub shown: bool, pub modal_shown: bool, + pub consent_overlay_shown: bool, pub occluded: bool, pub last_click: Option, pub malicious_origins: HashSet, } const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie"; +const APP_TITLE_BAR_HEIGHT: f32 = 48.0; #[cfg(any(windows, target_os = "macos"))] pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5; #[cfg(not(target_os = "linux"))] @@ -131,13 +133,16 @@ fn set_webview_visible_for_window( .and_then(|window| window.is_minimized().ok()) .unwrap_or(false); - let is_occluded = app + let (is_occluded, consent_overlay_shown) = app .state::>() .try_read() - .map(|state| state.occluded) - .unwrap_or(false); + .map(|state| (state.occluded, state.consent_overlay_shown)) + .unwrap_or((false, false)); - set_webview_visible(webview, visible && !is_minimized && !is_occluded); + set_webview_visible( + webview, + visible && !is_minimized && (!is_occluded || consent_overlay_shown), + ); } #[cfg(any(windows, target_os = "macos"))] @@ -195,7 +200,8 @@ async fn sync_ads_occlusion(app: &tauri::AppHandle) { } state.occluded = occluded; - let visible = state.shown && !state.modal_shown; + let visible = + state.shown && (!state.modal_shown || state.consent_overlay_shown); drop(state); if let Some(webview) = app.webviews().get("ads-window") { @@ -211,21 +217,36 @@ fn sync_webview_visibility_for_main_window( let is_minimized = main_window.is_minimized().unwrap_or(false); let was = was_minimized.load(Ordering::SeqCst); + let ads_state = if is_minimized { + None + } else { + match app.state::>().try_read() { + Ok(state) => Some(( + state.shown + && (!state.modal_shown || state.consent_overlay_shown) + && (!state.occluded || state.consent_overlay_shown), + state.consent_overlay_shown, + )), + Err(_) => None, + } + }; + let ads_visible = ads_state.map(|state| state.0).unwrap_or(false); + + if ads_state.map(|state| state.1).unwrap_or(false) + && let Some(webview) = app.webviews().get("ads-window") + && let Ok((position, size)) = + get_overlay_webview_position_for_window(main_window) + { + webview.set_position(position).ok(); + webview.set_size(size).ok(); + } + if is_minimized == was { return; } was_minimized.store(is_minimized, Ordering::SeqCst); - let ads_visible = if is_minimized { - false - } else { - match app.state::>().try_read() { - Ok(state) => state.shown && !state.modal_shown && !state.occluded, - Err(_) => false, - } - }; - let mut webviews = Vec::new(); let mut seen_webviews = HashSet::new(); @@ -254,6 +275,7 @@ pub fn init() -> TauriPlugin { app.manage(RwLock::new(AdsState { shown: true, modal_shown: false, + consent_overlay_shown: false, occluded: false, last_click: None, malicious_origins: HashSet::new(), @@ -269,7 +291,10 @@ pub fn init() -> TauriPlugin { .state::>() .try_read() .map(|state| { - state.shown && !state.modal_shown && !state.occluded + state.shown + && !state.modal_shown + && !state.consent_overlay_shown + && !state.occluded }) .unwrap_or(false); @@ -332,6 +357,8 @@ pub fn init() -> TauriPlugin { init_ads_window, hide_ads_window, show_ads_window, + show_ads_consent_overlay, + hide_ads_consent_overlay, record_ads_click, open_link, get_ads_personalization, @@ -358,6 +385,42 @@ fn get_webview_position( )) } +fn get_overlay_webview_position_for_window( + main_window: &tauri::Window, +) -> crate::api::Result<(PhysicalPosition, PhysicalSize)> { + let main_window_size = main_window.outer_size()?; + let title_bar_height = + APP_TITLE_BAR_HEIGHT * main_window.scale_factor()? as f32; + + Ok(( + PhysicalPosition::new(0.0, title_bar_height), + PhysicalSize::new( + main_window_size.width as f32, + (main_window_size.height as f32 - title_bar_height).max(0.0), + ), + )) +} + +fn get_overlay_webview_position( + app: &tauri::AppHandle, +) -> crate::api::Result<(PhysicalPosition, PhysicalSize)> { + let main_window = app.get_window("main").unwrap(); + + get_overlay_webview_position_for_window(&main_window) +} + +fn get_device_pixel_ratio( + app: &tauri::AppHandle, + dpr: Option, +) -> f32 { + dpr.unwrap_or_else(|| { + app.get_window("main") + .and_then(|window| window.scale_factor().ok()) + .map(|scale_factor| scale_factor as f32) + .unwrap_or(1.0) + }) +} + #[tauri::command] #[cfg(not(target_os = "linux"))] pub async fn init_ads_window( @@ -374,11 +437,17 @@ pub async fn init_ads_window( state.shown = true; } - if state.modal_shown { + if state.modal_shown && !state.consent_overlay_shown { return Ok(()); } - if let Ok((position, size)) = get_webview_position(&app, dpr) { + let layout = if state.consent_overlay_shown { + get_overlay_webview_position(&app) + } else { + get_webview_position(&app, dpr) + }; + + if let Ok((position, size)) = layout { let webview = if let Some(webview) = app.webviews().get("ads-window") { // set both the `hide`/`show` state and `position`, // to ensure that the webview is actually shown/hidden @@ -586,7 +655,11 @@ pub async fn show_ads_window( state.modal_shown = false; if state.shown { - let (position, size) = get_webview_position(&app, dpr)?; + let (position, size) = if state.consent_overlay_shown { + get_overlay_webview_position(&app)? + } else { + get_webview_position(&app, dpr)? + }; // set both the `hide`/`show` state and `position`, // to ensure that the webview is actually shown/hidden webview.set_size(size).ok(); @@ -610,8 +683,19 @@ pub async fn hide_ads_window( if reset.unwrap_or(false) { state.shown = false; + state.consent_overlay_shown = false; } else { state.modal_shown = true; + + if state.consent_overlay_shown { + let (position, size) = get_overlay_webview_position(&app)?; + webview.set_size(size).ok(); + webview.set_position(position).ok(); + webview.show().ok(); + set_webview_visible_for_window(&app, webview, true); + + return Ok(()); + } } // set both the `hide`/`show` state and `position`, @@ -625,6 +709,61 @@ pub async fn hide_ads_window( Ok(()) } +#[tauri::command] +pub async fn show_ads_consent_overlay( + app: tauri::AppHandle, +) -> crate::api::Result<()> { + if let Some(webview) = app.webviews().get("ads-window") { + let state = app.state::>(); + let mut state = state.write().await; + + // dont show for hidden ads so consent events cannot re-enable the webview. + if !state.shown { + return Ok(()); + } + + state.consent_overlay_shown = true; + + let (position, size) = get_overlay_webview_position(&app)?; + webview.set_size(size).ok(); + webview.set_position(position).ok(); + webview.show().ok(); + set_webview_visible_for_window(&app, webview, true); + } + + Ok(()) +} + +#[tauri::command] +pub async fn hide_ads_consent_overlay( + app: tauri::AppHandle, + dpr: Option, +) -> crate::api::Result<()> { + if let Some(webview) = app.webviews().get("ads-window") { + let state = app.state::>(); + let mut state = state.write().await; + + state.consent_overlay_shown = false; + + if state.shown && !state.modal_shown { + let dpr = get_device_pixel_ratio(&app, dpr); + let (position, size) = get_webview_position(&app, dpr)?; + + webview.set_size(size).ok(); + webview.set_position(position).ok(); + webview.show().ok(); + set_webview_visible_for_window(&app, webview, true); + } else { + webview + .set_position(PhysicalPosition::new(-1000, -1000)) + .ok(); + webview.hide().ok(); + } + } + + Ok(()) +} + #[tauri::command] pub async fn record_ads_click( app: tauri::AppHandle, diff --git a/apps/daedalus_client/src/error.rs b/apps/daedalus_client/src/error.rs index 6765341381..6afb321df4 100644 --- a/apps/daedalus_client/src/error.rs +++ b/apps/daedalus_client/src/error.rs @@ -27,6 +27,8 @@ pub enum ErrorKind { inner: Box, file: String, }, + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), #[error("Error acquiring semaphore: {0}")] Acquire(#[from] tokio::sync::AcquireError), #[error("Tracing error: {0}")] diff --git a/apps/daedalus_client/src/fabric.rs b/apps/daedalus_client/src/fabric.rs index 3d6f1eeb0c..8d9362bb73 100644 --- a/apps/daedalus_client/src/fabric.rs +++ b/apps/daedalus_client/src/fabric.rs @@ -1,3 +1,19 @@ +//! Fetches Fabric-compatible loader metadata. +//! +//! Fabric and Quilt both expose loader profiles for a concrete Minecraft +//! version, but Daedalus publishes templated profiles using +//! `${modrinth.gameVersion}`. A group is a set of Minecraft versions whose +//! upstream loader profiles have the same structure after the concrete +//! Minecraft version is replaced with `${modrinth.gameVersion}`. Fabric uses +//! one universal group, so its public profile paths stay as +//! `versions/{loader}.json`. Quilt has more than one group: versions before +//! 26.x include hashed/intermediary libraries, while 26.x versions do not. For +//! Quilt, Daedalus writes one templated profile per group at +//! `version-group/{group}/loader-version/{loader}`. + +use crate::metadata_groups::{ + UNIVERSAL_METADATA_GROUP, metadata_group_for_game_version, metadata_groups, +}; use crate::util::{download_file, fetch_json, format_url}; use crate::{ Error, FetchResult, MirrorArtifact, UploadFile, insert_mirrored_artifact, @@ -64,7 +80,112 @@ async fn fetch( &semaphore, ) .await?; + let all_loader_versions = fabric_manifest.loader.clone(); + let all_game_versions = fabric_manifest.game.clone(); + let metadata_groups = metadata_groups( + mod_loader, + all_game_versions.iter().map(|x| x.version.as_str()), + ); + if metadata_groups + .iter() + .any(|group| group.id != UNIVERSAL_METADATA_GROUP) + { + let loaders = all_loader_versions + .iter() + .filter(|x| !skip_versions.contains(&&*x.version)) + .collect::>(); + + let profile_requests = metadata_groups + .iter() + .flat_map(|group| { + loaders.iter().map(move |loader| ProfileRequest { + group: group.id.to_string(), + loader_profile_template_game_version: group + .loader_profile_template_game_version + .clone(), + game_versions: group.game_versions.clone(), + loader_version: loader.version.clone(), + url: format!( + "{}/versions/loader/{}/{}/profile/json", + meta_url, + group.loader_profile_template_game_version, + loader.version + ), + }) + }) + .collect::>(); + + fetch_metadata_profiles( + mod_loader, + format_version, + maven_url, + profile_requests, + &upload_files, + &mirror_artifacts, + &semaphore, + ) + .await?; + + let version_groups = metadata_groups + .iter() + .map(|group| daedalus::modded::VersionGroup { + id: group.id.to_string(), + loaders: loaders + .iter() + .map(|loader| { + let version_path = metadata_version_path( + mod_loader, + format_version, + &loader.version, + group.id, + ); + + daedalus::modded::LoaderVersion { + id: loader.version.clone(), + url: format_url(&version_path), + stable: loader.stable, + } + }) + .collect(), + }) + .collect(); + + let manifest = daedalus::modded::Manifest { + game_versions: all_game_versions + .into_iter() + .map(|game_version| { + let group = metadata_group_for_game_version( + &metadata_groups, + mod_loader, + &game_version.version, + ) + .expect("game version should have a metadata group"); + + daedalus::modded::Version { + id: game_version.version.clone(), + stable: game_version.stable, + version_group: Some(group.id.to_string()), + loaders: Vec::new(), + } + }) + .collect(), + version_groups, + }; + + upload_files.insert( + format!("{mod_loader}/v{format_version}/manifest.json"), + UploadFile { + file: bytes::Bytes::from(serde_json::to_vec(&manifest)?), + content_type: Some("application/json".to_string()), + }, + ); + + return Ok(FetchResult { + upload_files, + mirror_artifacts, + }); + } // We check Modrinth's manifest to find newly added loader versions, // intermediary/mapping artifacts, and game versions. let ( @@ -125,8 +246,6 @@ async fn fetch( ) }; - const DUMMY_GAME_VERSION: &str = "1.21"; - if !fetch_intermediary_versions.is_empty() { for x in &fetch_intermediary_versions { insert_mirrored_artifact( @@ -140,94 +259,38 @@ async fn fetch( } if !fetch_fabric_versions.is_empty() { - let fabric_version_manifest_urls = fetch_fabric_versions + let universal_group = metadata_groups .iter() - .map(|x| { - format!( + .find(|group| group.id == UNIVERSAL_METADATA_GROUP) + .expect("fabric metadata should have a universal group"); + let profile_requests = fetch_fabric_versions + .iter() + .map(|loader| ProfileRequest { + group: universal_group.id.to_string(), + loader_profile_template_game_version: universal_group + .loader_profile_template_game_version + .clone(), + game_versions: universal_group.game_versions.clone(), + loader_version: loader.version.clone(), + url: format!( "{}/versions/loader/{}/{}/profile/json", - meta_url, DUMMY_GAME_VERSION, x.version - ) + meta_url, + universal_group.loader_profile_template_game_version, + loader.version + ), }) .collect::>(); - let fabric_version_manifests = futures::future::try_join_all( - fabric_version_manifest_urls - .iter() - .map(|x| download_file(x, None, &semaphore)), + + fetch_metadata_profiles( + mod_loader, + format_version, + maven_url, + profile_requests, + &upload_files, + &mirror_artifacts, + &semaphore, ) - .await? - .into_iter() - .map(|x| serde_json::from_slice(&x)) - .collect::, serde_json::Error>>()?; - - let patched_version_manifests = fabric_version_manifests - .into_iter() - .map(|mut version_info| { - for lib in &mut version_info.libraries { - let new_name = lib - .name - .replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING); - - // Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries - if &*lib.name == "net.minecraft:launchwrapper:1.12" { - lib.url = Some( - "https://libraries.minecraft.net/".to_string(), - ); - } - - // If a library is not intermediary, we add it to mirror artifacts to be mirrored - if lib.name == new_name { - insert_mirrored_artifact( - &new_name, - None, - vec![ - lib.url - .clone() - .unwrap_or_else(|| maven_url.to_string()), - ], - false, - &mirror_artifacts, - )?; - } else { - lib.name = new_name; - } - - lib.url = Some(format_url("maven/")); - } - - version_info.id = version_info - .id - .replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING); - version_info.inherits_from = version_info - .inherits_from - .replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING); - - Ok(version_info) - }) - .collect::, Error>>()?; - let serialized_version_manifests = patched_version_manifests - .iter() - .map(|x| serde_json::to_vec(x).map(bytes::Bytes::from)) - .collect::, serde_json::Error>>()?; - - serialized_version_manifests - .into_iter() - .enumerate() - .for_each(|(index, bytes)| { - let loader = fetch_fabric_versions[index]; - - let version_path = format!( - "{mod_loader}/v{format_version}/versions/{}.json", - loader.version - ); - - upload_files.insert( - version_path, - UploadFile { - file: bytes, - content_type: Some("application/json".to_string()), - }, - ); - }); + .await?; } if !fetch_fabric_versions.is_empty() @@ -240,17 +303,20 @@ async fn fetch( let loader_versions = daedalus::modded::Version { id: DUMMY_REPLACE_STRING.to_string(), stable: true, - loaders: fabric_manifest - .loader - .into_iter() + version_group: None, + loaders: all_loader_versions + .iter() + .filter(|x| !skip_versions.contains(&&*x.version)) .map(|x| { - let version_path = format!( - "{mod_loader}/v{format_version}/versions/{}.json", - x.version, + let version_path = metadata_version_path( + mod_loader, + format_version, + &x.version, + UNIVERSAL_METADATA_GROUP, ); daedalus::modded::LoaderVersion { - id: x.version, + id: x.version.clone(), url: format_url(&version_path), stable: x.stable, } @@ -260,14 +326,16 @@ async fn fetch( let manifest = daedalus::modded::Manifest { game_versions: std::iter::once(loader_versions) - .chain(fabric_manifest.game.into_iter().map(|x| { + .chain(all_game_versions.into_iter().map(|x| { daedalus::modded::Version { id: x.version, stable: x.stable, + version_group: None, loaders: vec![], } })) .collect(), + version_groups: Vec::new(), }; upload_files.insert( @@ -285,6 +353,145 @@ async fn fetch( }) } +struct ProfileRequest { + group: String, + loader_profile_template_game_version: String, + game_versions: Vec, + loader_version: String, + url: String, +} + +fn metadata_version_path( + mod_loader: &str, + format_version: usize, + loader_version: &str, + group: &str, +) -> String { + if group == UNIVERSAL_METADATA_GROUP { + format!("{mod_loader}/v{format_version}/versions/{loader_version}.json") + } else { + format!( + "{mod_loader}/v{format_version}/version-group/{group}/loader-version/{loader_version}" + ) + } +} + +async fn fetch_metadata_profiles( + mod_loader: &str, + format_version: usize, + maven_url: &str, + profile_requests: Vec, + upload_files: &DashMap, + mirror_artifacts: &DashMap, + semaphore: &Arc, +) -> Result<(), Error> { + let version_manifests = futures::future::try_join_all( + profile_requests + .iter() + .map(|x| download_file(&x.url, None, semaphore)), + ) + .await? + .into_iter() + .map(|x| serde_json::from_slice(&x)) + .collect::, serde_json::Error>>()?; + + let patched_version_manifests = version_manifests + .into_iter() + .zip(profile_requests.iter()) + .map(|(mut version_info, request)| { + patch_version_info( + &mut version_info, + &request.loader_profile_template_game_version, + &request.game_versions, + maven_url, + mirror_artifacts, + )?; + + Ok(version_info) + }) + .collect::, Error>>()?; + let serialized_version_manifests = patched_version_manifests + .iter() + .map(|x| serde_json::to_vec(x).map(bytes::Bytes::from)) + .collect::, serde_json::Error>>()?; + + serialized_version_manifests + .into_iter() + .zip(profile_requests) + .for_each(|(bytes, request)| { + let version_path = metadata_version_path( + mod_loader, + format_version, + &request.loader_version, + &request.group, + ); + + upload_files.insert( + version_path, + UploadFile { + file: bytes, + content_type: Some("application/json".to_string()), + }, + ); + }); + + Ok(()) +} + +fn patch_version_info( + version_info: &mut PartialVersionInfo, + game_version: &str, + game_versions: &[String], + maven_url: &str, + mirror_artifacts: &DashMap, +) -> Result<(), Error> { + for lib in &mut version_info.libraries { + let new_name = lib.name.replace(game_version, DUMMY_REPLACE_STRING); + + // Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries + if &*lib.name == "net.minecraft:launchwrapper:1.12" { + lib.url = Some("https://libraries.minecraft.net/".to_string()); + } + let source_url = + lib.url.clone().unwrap_or_else(|| maven_url.to_string()); + + if lib.name == new_name { + insert_mirrored_artifact( + &new_name, + None, + vec![source_url], + false, + mirror_artifacts, + )?; + } else { + for concrete_game_version in game_versions { + let concrete_name = + lib.name.replace(game_version, concrete_game_version); + + insert_mirrored_artifact( + &concrete_name, + None, + vec![source_url.clone()], + false, + mirror_artifacts, + )?; + } + + lib.name = new_name; + } + + lib.url = Some(format_url("maven/")); + } + + version_info.id = + version_info.id.replace(game_version, DUMMY_REPLACE_STRING); + version_info.inherits_from = version_info + .inherits_from + .replace(game_version, DUMMY_REPLACE_STRING); + + Ok(()) +} + #[derive(Deserialize, Debug, Clone)] struct FabricVersions { pub loader: Vec, diff --git a/apps/daedalus_client/src/forge.rs b/apps/daedalus_client/src/forge.rs index 3e9293281f..3ebdd6f428 100644 --- a/apps/daedalus_client/src/forge.rs +++ b/apps/daedalus_client/src/forge.rs @@ -12,7 +12,10 @@ use itertools::Itertools; use serde::Deserialize; use serde::de::DeserializeOwned; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use tokio::sync::Semaphore; #[tracing::instrument(skip(semaphore))] @@ -243,12 +246,45 @@ async fn fetch( }; if !fetch_versions.is_empty() { - let forge_installers = futures::future::try_join_all( - fetch_versions - .iter() - .map(|x| download_file(&x.installer_url, None, &semaphore)), - ) - .await?; + let total_installers = fetch_versions.len(); + let downloaded_installers = Arc::new(AtomicUsize::new(0)); + + tracing::info!( + mod_loader, + total_files = total_installers, + "Downloading loader installers" + ); + + let forge_installers = + futures::future::try_join_all(fetch_versions.iter().map(|x| { + let downloaded_installers = downloaded_installers.clone(); + let semaphore = semaphore.clone(); + + async move { + let installer = + download_file(&x.installer_url, None, &semaphore) + .await?; + let downloaded = downloaded_installers + .fetch_add(1, Ordering::Relaxed) + + 1; + + if downloaded.is_multiple_of(100) + || downloaded == total_installers + { + tracing::info!( + mod_loader, + downloaded_files = downloaded, + remaining_files = + total_installers.saturating_sub(downloaded), + total_files = total_installers, + "Downloaded loader installers" + ); + } + + Ok::<_, Error>(installer) + } + })) + .await?; #[tracing::instrument(skip(raw, upload_files, mirror_artifacts))] async fn read_forge_installer( @@ -761,21 +797,23 @@ async fn fetch( .into_iter() .map(|(game_version, loaders)| { daedalus::modded::Version { - id: game_version, - stable: true, - loaders: loaders - .map(|x| daedalus::modded::LoaderVersion { - url: format_url(&format!( - "{mod_loader}/v{format_version}/versions/{}.json", - x.loader_version - )), - id: x.loader_version, - stable: false, - }) - .collect(), - } + id: game_version, + stable: true, + version_group: None, + loaders: loaders + .map(|x| daedalus::modded::LoaderVersion { + url: format_url(&format!( + "{mod_loader}/v{format_version}/versions/{}.json", + x.loader_version + )), + id: x.loader_version, + stable: false, + }) + .collect(), + } }) .collect(), + version_groups: Vec::new(), }; upload_files.insert( diff --git a/apps/daedalus_client/src/main.rs b/apps/daedalus_client/src/main.rs index 8b132a0c11..f9bda189ee 100644 --- a/apps/daedalus_client/src/main.rs +++ b/apps/daedalus_client/src/main.rs @@ -1,10 +1,14 @@ use crate::util::{ REQWEST_CLIENT, format_url, upload_file_to_bucket, - upload_url_to_bucket_mirrors, + upload_url_to_bucket_mirrors, write_file_to_local_output, + write_url_to_local_output_mirrors, }; use daedalus::get_path_from_artifact; use dashmap::{DashMap, DashSet}; -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use tokio::sync::Semaphore; use tracing_error::ErrorLayer; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; @@ -12,6 +16,7 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*}; mod error; mod fabric; mod forge; +mod metadata_groups; mod minecraft; pub mod util; @@ -44,51 +49,92 @@ async fn main() -> Result<()> { )); let mut fetch_result = FetchResult::default(); + let only_loader = dotenvy::var("DAEDALUS_ONLY").ok(); - match minecraft::fetch(semaphore.clone()).await { - Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), - Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"), + if should_fetch(only_loader.as_deref(), "minecraft") { + match minecraft::fetch(semaphore.clone()).await { + Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), + Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"), + } } - match fabric::fetch_fabric(semaphore.clone()).await { - Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), - Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"), + if should_fetch(only_loader.as_deref(), "fabric") { + match fabric::fetch_fabric(semaphore.clone()).await { + Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), + Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"), + } } - match fabric::fetch_quilt(semaphore.clone()).await { - Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), - Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"), + if should_fetch(only_loader.as_deref(), "quilt") { + match fabric::fetch_quilt(semaphore.clone()).await { + Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), + Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"), + } } - match forge::fetch_neo(semaphore.clone()).await { - Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), - Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"), + if should_fetch(only_loader.as_deref(), "neo") { + match forge::fetch_neo(semaphore.clone()).await { + Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), + Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"), + } } - match forge::fetch_forge(semaphore.clone()).await { - Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), - Err(err) => tracing::warn!(error = %err, "Forge fetch failed"), + if should_fetch(only_loader.as_deref(), "forge") { + match forge::fetch_forge(semaphore.clone()).await { + Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), + Err(err) => tracing::warn!(error = %err, "Forge fetch failed"), + } } let FetchResult { upload_files, mirror_artifacts, } = fetch_result; + let upload_file_total = upload_files.len(); + let mirror_file_total = mirror_artifacts.len(); - futures::future::try_join_all(upload_files.iter().map(|entry| { - upload_file_to_bucket( - entry.key().clone(), - entry.value().file.clone(), - entry.value().content_type.clone(), - &semaphore, - ) - })) - .await?; + if dotenvy::var("LOCAL_OUTPUT_DIR").is_ok() { + let written_files = Arc::new(AtomicUsize::new(0)); - futures::future::try_join_all(mirror_artifacts.iter().map(|entry| { - upload_url_to_bucket_mirrors( - format!("maven/{}", entry.key()), - entry + tracing::info!( + total_files = upload_file_total, + "Writing local metadata files" + ); + + futures::future::try_join_all(upload_files.iter().map(|entry| { + let path = entry.key().clone(); + let file = entry.value().file.clone(); + let written_files = written_files.clone(); + + async move { + write_file_to_local_output(&path, file).await?; + let written = written_files.fetch_add(1, Ordering::Relaxed) + 1; + + if written.is_multiple_of(100) || written == upload_file_total { + tracing::info!( + written_files = written, + remaining_files = + upload_file_total.saturating_sub(written), + total_files = upload_file_total, + "Wrote local metadata files" + ); + } + + Ok::<_, Error>(()) + } + })) + .await?; + + let written_mirror_files = Arc::new(AtomicUsize::new(0)); + + tracing::info!( + total_files = mirror_file_total, + "Writing local mirror files" + ); + + futures::future::try_join_all(mirror_artifacts.iter().map(|entry| { + let path = format!("maven/{}", entry.key()); + let mirrors = entry .value() .mirrors .iter() @@ -99,12 +145,117 @@ async fn main() -> Result<()> { format!("{}{}", mirror.path, entry.key()) } }) - .collect(), - entry.value().sha1.clone(), - &semaphore, - ) - })) - .await?; + .collect(); + let sha1 = entry.value().sha1.clone(); + let written_mirror_files = written_mirror_files.clone(); + let semaphore = semaphore.clone(); + + async move { + write_url_to_local_output_mirrors( + path, mirrors, sha1, &semaphore, + ) + .await?; + let written = + written_mirror_files.fetch_add(1, Ordering::Relaxed) + 1; + + if written.is_multiple_of(100) || written == mirror_file_total { + tracing::info!( + written_files = written, + remaining_files = + mirror_file_total.saturating_sub(written), + total_files = mirror_file_total, + "Wrote local mirror files" + ); + } + + Ok::<_, Error>(()) + } + })) + .await?; + } else { + let uploaded_files = Arc::new(AtomicUsize::new(0)); + + tracing::info!( + total_files = upload_file_total, + "Uploading metadata files" + ); + + futures::future::try_join_all(upload_files.iter().map(|entry| { + let path = entry.key().clone(); + let file = entry.value().file.clone(); + let content_type = entry.value().content_type.clone(); + let uploaded_files = uploaded_files.clone(); + let semaphore = semaphore.clone(); + + async move { + upload_file_to_bucket(path, file, content_type, &semaphore) + .await?; + let uploaded = + uploaded_files.fetch_add(1, Ordering::Relaxed) + 1; + + if uploaded.is_multiple_of(100) || uploaded == upload_file_total + { + tracing::info!( + uploaded_files = uploaded, + remaining_files = + upload_file_total.saturating_sub(uploaded), + total_files = upload_file_total, + "Uploaded metadata files" + ); + } + + Ok::<_, Error>(()) + } + })) + .await?; + + let uploaded_mirror_files = Arc::new(AtomicUsize::new(0)); + + tracing::info!( + total_files = mirror_file_total, + "Uploading mirror files" + ); + + futures::future::try_join_all(mirror_artifacts.iter().map(|entry| { + let path = format!("maven/{}", entry.key()); + let mirrors = entry + .value() + .mirrors + .iter() + .map(|mirror| { + if mirror.entire_url { + mirror.path.clone() + } else { + format!("{}{}", mirror.path, entry.key()) + } + }) + .collect(); + let sha1 = entry.value().sha1.clone(); + let uploaded_mirror_files = uploaded_mirror_files.clone(); + let semaphore = semaphore.clone(); + + async move { + upload_url_to_bucket_mirrors(path, mirrors, sha1, &semaphore) + .await?; + let uploaded = + uploaded_mirror_files.fetch_add(1, Ordering::Relaxed) + 1; + + if uploaded.is_multiple_of(100) || uploaded == mirror_file_total + { + tracing::info!( + uploaded_files = uploaded, + remaining_files = + mirror_file_total.saturating_sub(uploaded), + total_files = mirror_file_total, + "Uploaded mirror files" + ); + } + + Ok::<_, Error>(()) + } + })) + .await?; + } if dotenvy::var("CLOUDFLARE_INTEGRATION") .ok() @@ -151,6 +302,20 @@ async fn main() -> Result<()> { Ok(()) } +fn should_fetch(only_loader: Option<&str>, loader: &str) -> bool { + let Some(only_loader) = only_loader else { + return true; + }; + + only_loader.split(',').any(|entry| { + let entry = entry.trim(); + + entry.eq_ignore_ascii_case("all") + || entry.eq_ignore_ascii_case(loader) + || (loader == "neo" && entry.eq_ignore_ascii_case("neoforge")) + }) +} + pub struct UploadFile { file: bytes::Bytes, content_type: Option, @@ -248,11 +413,13 @@ fn check_env_vars() -> bool { failed |= check_var::("BASE_URL"); - failed |= check_var::("S3_ACCESS_TOKEN"); - failed |= check_var::("S3_SECRET"); - failed |= check_var::("S3_URL"); - failed |= check_var::("S3_REGION"); - failed |= check_var::("S3_BUCKET_NAME"); + if dotenvy::var("LOCAL_OUTPUT_DIR").is_err() { + failed |= check_var::("S3_ACCESS_TOKEN"); + failed |= check_var::("S3_SECRET"); + failed |= check_var::("S3_URL"); + failed |= check_var::("S3_REGION"); + failed |= check_var::("S3_BUCKET_NAME"); + } if dotenvy::var("CLOUDFLARE_INTEGRATION") .ok() diff --git a/apps/daedalus_client/src/metadata_groups.rs b/apps/daedalus_client/src/metadata_groups.rs new file mode 100644 index 0000000000..45b2c01b59 --- /dev/null +++ b/apps/daedalus_client/src/metadata_groups.rs @@ -0,0 +1,145 @@ +//! Determines how version info is generated for pairs of game and loader +//! versions. +//! +//! When a user installs a version of the game, they install two things: the +//! game (of some specific version), and a loader (of some other specific +//! version). Each combination of game and loader version requires a specific +//! configuration, like a specific set of libraries that must be downloaded and +//! run along with the game. However, some versions of the game or loader may +//! change configuration requirements without the other version being affected. +//! For example, pre-26.x game versions with Quilt require the Quilt `hashed` +//! libraries to also be downloaded. However, 26.x and later don't require the +//! `hashed` libraries, and don't even have a download for them. The problem is +//! that Quilt loader 0.30.0 can be used for both pre-26.x and 26.x - but our v0 +//! manifest files can't differentiate the two. The result is that you either +//! break compatibility for 0.30.0 game versions pre-26.x, or break 0.30.0 +//! on 26.x and later. +//! +//! To fix this, v1 introduces the concept of *version groups*: game versions +//! before 26.x are version group v1, and 26.x and later are v2. Then, we +//! parameterize our version info on both version group and loader version, +//! letting us specify the right configuration based on both game version and +//! loader version. +//! +//! Why not parameterize on game version and loader version directly? Most game +//! versions have the same configuration as their surrounding game versions, so +//! we'd end up with many duplicate configurations: the number of game versions +//! multiplied by the number of loader versions. +//! +//! This file lets you configure what game versions are grouped together. +//! +//! Each version group is templated from a specific game version - e.g. game +//! version 1.21 is used as the template file for 1.20, 1.19, etc. + +pub const UNIVERSAL_METADATA_GROUP: &str = "universal"; +pub const QUILT_LEGACY_METADATA_GROUP: &str = "v1"; +pub const QUILT_MODERN_METADATA_GROUP: &str = "v2"; + +pub struct MetadataGroup { + pub id: &'static str, + /// Minecraft version used to fetch and template this group's loader profiles. + pub loader_profile_template_game_version: String, + pub game_versions: Vec, +} + +pub fn metadata_groups<'a>( + mod_loader: &str, + game_versions: impl IntoIterator, +) -> Vec { + // Non-Quilt loaders don't need the concept of version groups, so we just + // make one "universal" group, and template it on 1.21. + if mod_loader != "quilt" { + return vec![MetadataGroup { + id: UNIVERSAL_METADATA_GROUP, + loader_profile_template_game_version: "1.21".to_string(), + game_versions: game_versions + .into_iter() + .map(|x| x.to_string()) + .collect(), + }]; + } + + let game_versions = game_versions.into_iter().collect::>(); + let legacy_game_versions = game_versions + .iter() + .copied() + .filter(|game_version| { + metadata_group_id_for_game_version(mod_loader, game_version) + == QUILT_LEGACY_METADATA_GROUP + }) + .collect::>(); + let modern_game_versions = game_versions + .iter() + .copied() + .filter(|game_version| { + metadata_group_id_for_game_version(mod_loader, game_version) + == QUILT_MODERN_METADATA_GROUP + }) + .collect::>(); + + let mut groups = Vec::new(); + + if !legacy_game_versions.is_empty() { + groups.push(MetadataGroup { + id: QUILT_LEGACY_METADATA_GROUP, + loader_profile_template_game_version: legacy_game_versions + .iter() + .find(|x| **x == "1.21") + .copied() + .unwrap_or(legacy_game_versions[0]) + .to_string(), + game_versions: legacy_game_versions + .iter() + .map(|x| x.to_string()) + .collect(), + }); + } + + if !modern_game_versions.is_empty() { + groups.push(MetadataGroup { + id: QUILT_MODERN_METADATA_GROUP, + loader_profile_template_game_version: modern_game_versions[0] + .to_string(), + game_versions: modern_game_versions + .iter() + .map(|x| x.to_string()) + .collect(), + }); + } + + groups +} + +pub fn metadata_group_for_game_version<'a>( + groups: &'a [MetadataGroup], + mod_loader: &str, + game_version: &str, +) -> Option<&'a MetadataGroup> { + let group_id = metadata_group_id_for_game_version(mod_loader, game_version); + + groups.iter().find(|group| group.id == group_id) +} + +fn metadata_group_id_for_game_version( + mod_loader: &str, + game_version: &str, +) -> &'static str { + if mod_loader == "quilt" && is_modern_quilt_game_version(game_version) { + QUILT_MODERN_METADATA_GROUP + } else if mod_loader == "quilt" { + QUILT_LEGACY_METADATA_GROUP + } else { + UNIVERSAL_METADATA_GROUP + } +} + +// Update these Quilt group boundaries if upstream loader profiles gain another +// structural incompatibility between Minecraft versions. +fn is_modern_quilt_game_version(game_version: &str) -> bool { + let major = game_version + .split(['.', 'w']) + .next() + .and_then(|x| x.parse::().ok()); + + major.is_some_and(|x| x >= 26) +} diff --git a/apps/daedalus_client/src/util.rs b/apps/daedalus_client/src/util.rs index 6d331d839e..0303e02bde 100644 --- a/apps/daedalus_client/src/util.rs +++ b/apps/daedalus_client/src/util.rs @@ -3,7 +3,11 @@ use bytes::Bytes; use s3::creds::Credentials; use s3::{Bucket, Region}; use serde::de::DeserializeOwned; -use std::sync::{Arc, LazyLock}; +use std::path::PathBuf; +use std::sync::{ + Arc, LazyLock, + atomic::{AtomicUsize, Ordering}, +}; use tokio::sync::Semaphore; static BUCKET: LazyLock = LazyLock::new(|| { @@ -55,6 +59,8 @@ pub static REQWEST_CLIENT: LazyLock = LazyLock::new(|| { .unwrap() }); +static DOWNLOADED_FILE_COUNT: AtomicUsize = AtomicUsize::new(0); + #[tracing::instrument(skip(bytes, semaphore))] pub async fn upload_file_to_bucket( path: String, @@ -135,6 +141,74 @@ pub async fn upload_url_to_bucket( Ok(()) } +pub async fn write_file_to_local_output( + path: &str, + bytes: Bytes, +) -> Result<(), Error> { + let output_path = local_output_path(path)?; + + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + tokio::fs::write(output_path, bytes).await?; + + Ok(()) +} + +pub async fn write_url_to_local_output_mirrors( + output_path: String, + mirrors: Vec, + sha1: Option, + semaphore: &Arc, +) -> Result<(), Error> { + if mirrors.is_empty() { + return Err(ErrorKind::InvalidInput( + "No mirrors provided!".to_string(), + ) + .into()); + } + + for (index, mirror) in mirrors.iter().enumerate() { + let result = write_url_to_local_output( + output_path.clone(), + mirror.clone(), + sha1.clone(), + semaphore, + ) + .await; + + if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) { + return result; + } + } + + unreachable!() +} + +async fn write_url_to_local_output( + path: String, + url: String, + sha1: Option, + semaphore: &Arc, +) -> Result<(), Error> { + let data = download_file(&url, sha1.as_deref(), semaphore).await?; + + write_file_to_local_output(&path, data).await?; + + Ok(()) +} + +fn local_output_path(path: &str) -> Result { + let output_dir = dotenvy::var("LOCAL_OUTPUT_DIR").map_err(|_| { + ErrorKind::InvalidInput( + "LOCAL_OUTPUT_DIR is required for local output".to_string(), + ) + })?; + + Ok(PathBuf::from(output_dir).join(path)) +} + #[tracing::instrument(skip(bytes))] pub async fn sha1_async(bytes: Bytes) -> Result { let hash = tokio::task::spawn_blocking(move || { @@ -182,6 +256,16 @@ pub async fn download_file( } } + let downloaded = DOWNLOADED_FILE_COUNT + .fetch_add(1, Ordering::Relaxed) + + 1; + if downloaded.is_multiple_of(100) { + tracing::info!( + downloaded_files = downloaded, + "Downloaded metadata files" + ); + } + return Ok(bytes); } else if attempt <= RETRIES { continue; diff --git a/apps/docs/public/openapi.yaml b/apps/docs/public/openapi.yaml index b49357c030..110db62e58 100644 --- a/apps/docs/public/openapi.yaml +++ b/apps/docs/public/openapi.yaml @@ -692,6 +692,12 @@ components: type: string description: The ID of the project example: AABBCCDD + all_project_types: + type: array + items: + type: string + description: All project types across every version of the project, unlike `project_type` which only reflects a version-specific type + example: [mod, plugin, datapack] author: type: string description: The username of the project's author @@ -748,6 +754,7 @@ components: - client_side - server_side - project_id + - all_project_types - author - versions - follows @@ -1948,6 +1955,7 @@ paths: These are the most commonly used facet types: - `project_type` + - `all_project_types` (matches against every project type across all of the project's versions, not just the primary/version-specific type) - `categories` (loaders are lumped in with categories in search) - `versions` - `client_side` diff --git a/apps/frontend/src/assets/styles/global.scss b/apps/frontend/src/assets/styles/global.scss index 97142a7228..7013d7b54a 100644 --- a/apps/frontend/src/assets/styles/global.scss +++ b/apps/frontend/src/assets/styles/global.scss @@ -482,3 +482,8 @@ input { .button-transparent { box-shadow: none; } + +.qc-cmp2-close-tooltip { + background-color: transparent; + color: hsl(145, 78%, 28%); +} diff --git a/apps/frontend/src/components/ui/ProjectDownloadModal/CompatibleVersionCard.vue b/apps/frontend/src/components/ui/ProjectDownloadModal/CompatibleVersionCard.vue new file mode 100644 index 0000000000..c8b5bf66c8 --- /dev/null +++ b/apps/frontend/src/components/ui/ProjectDownloadModal/CompatibleVersionCard.vue @@ -0,0 +1,184 @@ + + + + + + + + + + + {{ version.version_number }} + + + + + + + {{ publishedLabel }} + + + + {{ primaryFileSizeLabel }} + + + + + + + + {{ formatMessage(messages.download) }} + + + + + + + diff --git a/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadDependencies.vue b/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadDependencies.vue index d70ae48c27..bf40f0ae9f 100644 --- a/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadDependencies.vue +++ b/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadDependencies.vue @@ -1,494 +1,111 @@ - - - - {{ sectionTitle }} - + + + + {{ formatMessage(messages.dependenciesTitle) }} + + + + + + + resourcepacks + + + + + + + resourcepacks + + + + + - + - - + + + + {{ formatMessage(messages.recommendedTitle) }} + + + + diff --git a/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadDependency.vue b/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadDependency.vue index 6863a0536e..e1959994ea 100644 --- a/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadDependency.vue +++ b/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadDependency.vue @@ -1,24 +1,24 @@ - + - + {{ dependency.name }} @@ -36,13 +36,25 @@ v-else ref="dependencyNameRef" v-tooltip="truncatedTooltip(dependencyNameRef, dependency.name)" - class="min-w-0 truncate text-base font-semibold text-contrast" + class="min-w-0 truncate bg-surface-2 text-base text-contrast" + :class="dependency.isAdditionalFile ? 'font-medium' : 'font-semibold'" > {{ dependency.name }} - - {{ dependency.typeLabel }} + + {{ metadataLabel }} + + {{ metadataLabel }} + - + - + @@ -102,10 +118,13 @@ interface DownloadDependencyRow { name: string icon?: string fallbackIcon?: Component + hideIcon?: boolean + isAdditionalFile?: boolean projectHref?: string downloadHref?: string filename?: string fileSize?: number + metadataLabel?: string typeLabel: string unavailableTooltip: string dependencies: DownloadDependencyRow[] @@ -123,6 +142,13 @@ const { formatMessage } = useVIntl() const formatBytes = useFormatBytes() const dependencyNameRef = ref(null) +const metadataLabel = computed(() => props.dependency.metadataLabel ?? props.dependency.typeLabel) +const metadataTooltip = computed(() => { + if (props.dependency.isAdditionalFile) return null + if (metadataLabel.value === props.dependency.typeLabel) return null + return metadataLabel.value +}) + const downloadTooltip = computed(() => { const filename = props.dependency.filename || props.dependency.name diff --git a/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadProject.vue b/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadProject.vue index 60d00a0f5c..0f610f51b7 100644 --- a/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadProject.vue +++ b/apps/frontend/src/components/ui/ProjectDownloadModal/DownloadProject.vue @@ -80,107 +80,73 @@ - - - - {{ formatMessage(messages.compatibleVersionTitle) }} - - - - - - {{ - formatMessage( - downloadingSelectedVersion - ? messages.downloadingSelectedVersion - : messages.downloadAllSelectedVersion, - { - current: selectedVersionDownloadProgress.current, - total: selectedVersionDownloadProgress.total, - }, - ) - }} - - - - + - - - - - {{ selectedVersion.version_number }} - - - - - - {{ selectedVersion.name }} - - - - - - - - + {{ formatMessage(messages.compatibleVersionTitle) }} + + - - {{ - formatMessage(messages.noVersionsAvailable, { - gameVersion: currentGameVersion, - platform: currentPlatformText, - }) - }} -
resourcepacks
- {{ selectedVersion.name }} -
- {{ - formatMessage(messages.noVersionsAvailable, { - gameVersion: currentGameVersion, - platform: currentPlatformText, - }) - }} -