You've already forked AstralRinth
1215 lines
37 KiB
Diff
1215 lines
37 KiB
Diff
diff --git a/apps/app-frontend/src/components/ui/CurseForgeProfileImportModal.vue b/apps/app-frontend/src/components/ui/CurseForgeProfileImportModal.vue
|
|
new file mode 100644
|
|
index 00000000..008d2d04
|
|
--- /dev/null
|
|
+++ b/apps/app-frontend/src/components/ui/CurseForgeProfileImportModal.vue
|
|
@@ -0,0 +1,401 @@
|
|
+<template>
|
|
+ <ModalWrapper ref="modal" :header="'Import from CurseForge Profile Code'">
|
|
+ <div class="modal-body">
|
|
+ <div class="input-row">
|
|
+ <p class="input-label">Profile Code</p>
|
|
+ <div class="iconified-input">
|
|
+ <SearchIcon aria-hidden="true" class="text-lg" />
|
|
+ <input
|
|
+ ref="codeInput"
|
|
+ v-model="profileCode"
|
|
+ autocomplete="off"
|
|
+ class="h-12 card-shadow"
|
|
+ spellcheck="false"
|
|
+ type="text"
|
|
+ placeholder="Enter CurseForge profile code"
|
|
+ maxlength="20"
|
|
+ @keyup.enter="importProfile"
|
|
+ />
|
|
+ <Button v-if="profileCode" class="r-btn" @click="() => (profileCode = '')">
|
|
+ <XIcon />
|
|
+ </Button>
|
|
+ </div>
|
|
+ </div>
|
|
+
|
|
+ <div v-if="metadata && !importing" class="profile-info">
|
|
+ <h3>Profile Information</h3>
|
|
+ <p><strong>Name:</strong> {{ metadata.name }}</p>
|
|
+ </div>
|
|
+
|
|
+ <div v-if="error" class="error-message">
|
|
+ <p>{{ error }}</p>
|
|
+ </div>
|
|
+
|
|
+ <div v-if="importing && importProgress.visible" class="progress-section">
|
|
+ <div class="progress-info">
|
|
+ <span class="progress-text">{{ importProgress.message }}</span>
|
|
+ <span class="progress-percentage">{{ Math.floor(importProgress.percentage) }}%</span>
|
|
+ </div>
|
|
+ <div class="progress-bar-container">
|
|
+ <div
|
|
+ class="progress-bar"
|
|
+ :style="{ width: `${importProgress.percentage}%` }"
|
|
+ ></div>
|
|
+ </div>
|
|
+ </div>
|
|
+
|
|
+ <div class="button-row">
|
|
+ <Button @click="hide" :disabled="importing">
|
|
+ <XIcon />
|
|
+ Cancel
|
|
+ </Button>
|
|
+ <Button
|
|
+ v-if="!metadata"
|
|
+ @click="fetchMetadata"
|
|
+ :disabled="!profileCode.trim() || fetching"
|
|
+ color="secondary"
|
|
+ >
|
|
+ <SearchIcon v-if="!fetching" />
|
|
+ {{ fetching ? 'Checking...' : 'Check Profile' }}
|
|
+ </Button>
|
|
+ <Button
|
|
+ v-if="metadata"
|
|
+ @click="importProfile"
|
|
+ :disabled="importing"
|
|
+ color="primary"
|
|
+ >
|
|
+ <DownloadIcon v-if="!importing" />
|
|
+ {{ importing ? 'Importing...' : 'Import Profile' }}
|
|
+ </Button>
|
|
+ </div>
|
|
+ </div>
|
|
+ </ModalWrapper>
|
|
+</template>
|
|
+
|
|
+<script setup>
|
|
+import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
|
+import { useRouter } from 'vue-router'
|
|
+import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
|
+import { Button } from '@modrinth/ui'
|
|
+import {
|
|
+ XIcon,
|
|
+ SearchIcon,
|
|
+ DownloadIcon
|
|
+} from '@modrinth/assets'
|
|
+import {
|
|
+ fetch_curseforge_profile_metadata,
|
|
+ import_curseforge_profile
|
|
+} from '@/helpers/import.js'
|
|
+import { handleError } from '@/store/notifications.js'
|
|
+import { trackEvent } from '@/helpers/analytics'
|
|
+import { loading_listener } from '@/helpers/events.js'
|
|
+
|
|
+const props = defineProps({
|
|
+ closeParent: {
|
|
+ type: Function,
|
|
+ default: null
|
|
+ }
|
|
+})
|
|
+
|
|
+const router = useRouter()
|
|
+const modal = ref(null)
|
|
+const codeInput = ref(null)
|
|
+const profileCode = ref('')
|
|
+const metadata = ref(null)
|
|
+const fetching = ref(false)
|
|
+const importing = ref(false)
|
|
+const error = ref('')
|
|
+const importProgress = ref({
|
|
+ visible: false,
|
|
+ percentage: 0,
|
|
+ message: 'Starting import...',
|
|
+ totalMods: 0,
|
|
+ downloadedMods: 0
|
|
+})
|
|
+
|
|
+let unlistenLoading = null
|
|
+let activeLoadingBarId = null
|
|
+let progressFallbackTimer = null
|
|
+
|
|
+defineExpose({
|
|
+ show: () => {
|
|
+ profileCode.value = ''
|
|
+ metadata.value = null
|
|
+ fetching.value = false
|
|
+ importing.value = false
|
|
+ error.value = ''
|
|
+ importProgress.value = {
|
|
+ visible: false,
|
|
+ percentage: 0,
|
|
+ message: 'Starting import...',
|
|
+ totalMods: 0,
|
|
+ downloadedMods: 0
|
|
+ }
|
|
+ modal.value?.show()
|
|
+
|
|
+ nextTick(() => {
|
|
+ setTimeout(() => {
|
|
+ codeInput.value?.focus()
|
|
+ }, 100)
|
|
+ })
|
|
+
|
|
+ trackEvent('CurseForgeProfileImportStart', { source: 'ImportModal' })
|
|
+ },
|
|
+})
|
|
+
|
|
+const hide = () => {
|
|
+ modal.value?.hide()
|
|
+}
|
|
+
|
|
+const fetchMetadata = async () => {
|
|
+ if (!profileCode.value.trim()) return
|
|
+
|
|
+ fetching.value = true
|
|
+ error.value = ''
|
|
+
|
|
+ try {
|
|
+ const result = await fetch_curseforge_profile_metadata(profileCode.value.trim())
|
|
+ metadata.value = result
|
|
+ trackEvent('CurseForgeProfileMetadataFetched', {
|
|
+ profileCode: profileCode.value.trim()
|
|
+ })
|
|
+ } catch (err) {
|
|
+ console.error('Failed to fetch CurseForge profile metadata:', err)
|
|
+ error.value = 'Failed to fetch profile information. Please check the code and try again.'
|
|
+ handleError(err)
|
|
+ } finally {
|
|
+ fetching.value = false
|
|
+ }
|
|
+}
|
|
+
|
|
+const importProfile = async () => {
|
|
+ if (!profileCode.value.trim()) return
|
|
+
|
|
+ importing.value = true
|
|
+ error.value = ''
|
|
+ activeLoadingBarId = null // Reset for new import session
|
|
+ importProgress.value = {
|
|
+ visible: true,
|
|
+ percentage: 0,
|
|
+ message: 'Starting import...',
|
|
+ totalMods: 0,
|
|
+ downloadedMods: 0
|
|
+ }
|
|
+
|
|
+ // Fallback progress timer in case loading events don't work
|
|
+ progressFallbackTimer = setInterval(() => {
|
|
+ if (importing.value && importProgress.value.percentage < 90) {
|
|
+ // Slowly increment progress as a fallback
|
|
+ importProgress.value.percentage = Math.min(90, importProgress.value.percentage + 1)
|
|
+ }
|
|
+ }, 1000)
|
|
+
|
|
+ try {
|
|
+ const { result, profilePath } = await import_curseforge_profile(profileCode.value.trim())
|
|
+
|
|
+ trackEvent('CurseForgeProfileImported', {
|
|
+ profileCode: profileCode.value.trim()
|
|
+ })
|
|
+
|
|
+ hide()
|
|
+
|
|
+ // Close the parent modal if provided
|
|
+ if (props.closeParent) {
|
|
+ props.closeParent()
|
|
+ }
|
|
+
|
|
+ // Navigate to the imported profile
|
|
+ await router.push(`/instance/${encodeURIComponent(profilePath)}`)
|
|
+ } catch (err) {
|
|
+ console.error('Failed to import CurseForge profile:', err)
|
|
+ error.value = 'Failed to import profile. Please try again.'
|
|
+ handleError(err)
|
|
+ } finally {
|
|
+ importing.value = false
|
|
+ importProgress.value.visible = false
|
|
+ if (progressFallbackTimer) {
|
|
+ clearInterval(progressFallbackTimer)
|
|
+ progressFallbackTimer = null
|
|
+ }
|
|
+ activeLoadingBarId = null
|
|
+ }
|
|
+}
|
|
+
|
|
+onMounted(async () => {
|
|
+ // Listen for loading events to update progress
|
|
+ unlistenLoading = await loading_listener((event) => {
|
|
+ console.log('Loading event received:', event) // Debug log
|
|
+
|
|
+ // Handle all loading events that could be related to CurseForge profile import
|
|
+ const isCurseForgeEvent = event.event?.type === 'curseforge_profile_download'
|
|
+ const hasProfileName = event.event?.profile_name && importing.value
|
|
+
|
|
+ if ((isCurseForgeEvent || hasProfileName) && importing.value) {
|
|
+ // Store the loading bar ID for this import session
|
|
+ if (!activeLoadingBarId) {
|
|
+ activeLoadingBarId = event.loader_uuid
|
|
+ }
|
|
+
|
|
+ // Only process events for our current import session
|
|
+ if (event.loader_uuid === activeLoadingBarId) {
|
|
+ if (event.fraction !== null && event.fraction !== undefined) {
|
|
+ const baseProgress = (event.fraction || 0) * 100
|
|
+
|
|
+ // Calculate custom progress based on the message
|
|
+ let finalProgress = baseProgress
|
|
+ const message = event.message || 'Importing profile...'
|
|
+
|
|
+ // Custom progress calculation for different stages
|
|
+ if (message.includes('Fetching') || message.includes('metadata')) {
|
|
+ finalProgress = Math.min(10, baseProgress)
|
|
+ } else if (message.includes('Downloading profile ZIP') || message.includes('profile ZIP')) {
|
|
+ finalProgress = Math.min(15, 10 + (baseProgress - 10) * 0.5)
|
|
+ } else if (message.includes('Extracting') || message.includes('ZIP')) {
|
|
+ finalProgress = Math.min(20, 15 + (baseProgress - 15) * 0.5)
|
|
+ } else if (message.includes('Configuring') || message.includes('profile')) {
|
|
+ finalProgress = Math.min(30, 20 + (baseProgress - 20) * 0.5)
|
|
+ } else if (message.includes('Copying') || message.includes('files')) {
|
|
+ finalProgress = Math.min(40, 30 + (baseProgress - 30) * 0.5)
|
|
+ } else if (message.includes('Downloaded mod') && message.includes(' of ')) {
|
|
+ // Parse "Downloaded mod X of Y" message
|
|
+ const match = message.match(/Downloaded mod (\d+) of (\d+)/)
|
|
+ if (match) {
|
|
+ const current = parseInt(match[1])
|
|
+ const total = parseInt(match[2])
|
|
+ // Mods take 40% of progress (from 40% to 80%)
|
|
+ const modProgress = (current / total) * 40
|
|
+ finalProgress = 40 + modProgress
|
|
+ } else {
|
|
+ finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.5)
|
|
+ }
|
|
+ } else if (message.includes('Downloading mod') || message.includes('mods')) {
|
|
+ // General mod downloading stage (40% to 80%)
|
|
+ finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.4)
|
|
+ } else if (message.includes('Installing Minecraft') || message.includes('Minecraft')) {
|
|
+ finalProgress = Math.min(95, 80 + (baseProgress - 80) * 0.75)
|
|
+ } else if (message.includes('Finalizing') || message.includes('completed')) {
|
|
+ finalProgress = Math.min(100, 95 + (baseProgress - 95))
|
|
+ } else {
|
|
+ // Default: use the base progress but ensure minimum progression
|
|
+ finalProgress = Math.max(importProgress.value.percentage, baseProgress)
|
|
+ }
|
|
+
|
|
+ importProgress.value.percentage = Math.min(100, Math.max(0, finalProgress))
|
|
+ importProgress.value.message = message
|
|
+ } else {
|
|
+ // Loading complete
|
|
+ importProgress.value.percentage = 100
|
|
+ importProgress.value.message = 'Import completed!'
|
|
+ activeLoadingBarId = null
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ })
|
|
+})
|
|
+
|
|
+onUnmounted(() => {
|
|
+ if (unlistenLoading) {
|
|
+ unlistenLoading()
|
|
+ }
|
|
+ if (progressFallbackTimer) {
|
|
+ clearInterval(progressFallbackTimer)
|
|
+ }
|
|
+})
|
|
+</script>
|
|
+
|
|
+<style lang="scss" scoped>
|
|
+.modal-body {
|
|
+ display: flex;
|
|
+ flex-direction: column;
|
|
+ gap: 1rem;
|
|
+ padding: 1rem;
|
|
+}
|
|
+
|
|
+.input-row {
|
|
+ display: flex;
|
|
+ flex-direction: column;
|
|
+ gap: 0.5rem;
|
|
+}
|
|
+
|
|
+.input-label {
|
|
+ font-weight: 600;
|
|
+ color: var(--color-contrast);
|
|
+ margin: 0;
|
|
+}
|
|
+
|
|
+.profile-info {
|
|
+ background: var(--color-bg);
|
|
+ border: 1px solid var(--color-button);
|
|
+ border-radius: var(--radius-md);
|
|
+ padding: 1rem;
|
|
+
|
|
+ h3 {
|
|
+ margin: 0 0 0.5rem 0;
|
|
+ color: var(--color-contrast);
|
|
+ }
|
|
+
|
|
+ p {
|
|
+ margin: 0.25rem 0;
|
|
+ color: var(--color-base);
|
|
+ }
|
|
+}
|
|
+
|
|
+.error-message {
|
|
+ background: var(--color-red);
|
|
+ border: 1px solid var(--color-red);
|
|
+ border-radius: var(--radius-md);
|
|
+ padding: 0.75rem;
|
|
+
|
|
+ p {
|
|
+ margin: 0;
|
|
+ color: var(--color-contrast);
|
|
+ font-weight: 500;
|
|
+ }
|
|
+}
|
|
+
|
|
+.progress-section {
|
|
+ display: flex;
|
|
+ flex-direction: column;
|
|
+ gap: 0.5rem;
|
|
+}
|
|
+
|
|
+.progress-info {
|
|
+ display: flex;
|
|
+ justify-content: space-between;
|
|
+ align-items: center;
|
|
+
|
|
+ .progress-text {
|
|
+ color: var(--color-base);
|
|
+ font-size: 0.875rem;
|
|
+ }
|
|
+
|
|
+ .progress-percentage {
|
|
+ color: var(--color-contrast);
|
|
+ font-size: 0.875rem;
|
|
+ font-weight: 600;
|
|
+ }
|
|
+}
|
|
+
|
|
+.progress-bar-container {
|
|
+ width: 100%;
|
|
+ height: 4px;
|
|
+ background: var(--color-button);
|
|
+ border-radius: 2px;
|
|
+ overflow: hidden;
|
|
+}
|
|
+
|
|
+.progress-bar {
|
|
+ height: 100%;
|
|
+ background: var(--color-brand);
|
|
+ border-radius: 2px;
|
|
+ transition: width 0.3s ease;
|
|
+ min-width: 0;
|
|
+}
|
|
+
|
|
+.button-row {
|
|
+ display: flex;
|
|
+ gap: 0.5rem;
|
|
+ justify-content: flex-end;
|
|
+ margin-top: auto;
|
|
+}
|
|
+</style>
|
|
diff --git a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue b/apps/app-frontend/src/components/ui/InstanceCreationModal.vue
|
|
index ee6328ff..c086afaf 100644
|
|
--- a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue
|
|
+++ b/apps/app-frontend/src/components/ui/InstanceCreationModal.vue
|
|
@@ -163,6 +163,14 @@
|
|
<div v-else class="table-content empty">No profiles found</div>
|
|
</div>
|
|
<div class="button-row">
|
|
+ <Button
|
|
+ v-if="selectedProfileType.name === 'Curseforge'"
|
|
+ @click="showCurseForgeProfileModal"
|
|
+ :disabled="loading"
|
|
+ >
|
|
+ <CodeIcon />
|
|
+ Import from Profile Code
|
|
+ </Button>
|
|
<Button
|
|
:disabled="
|
|
loading ||
|
|
@@ -194,10 +202,12 @@
|
|
</div>
|
|
</div>
|
|
</ModalWrapper>
|
|
+ <CurseForgeProfileImportModal ref="curseforgeProfileModal" :close-parent="hide" />
|
|
</template>
|
|
|
|
<script setup>
|
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
|
+import CurseForgeProfileImportModal from '@/components/ui/CurseForgeProfileImportModal.vue'
|
|
import {
|
|
CodeIcon,
|
|
FolderOpenIcon,
|
|
@@ -283,6 +293,11 @@ const hide = () => {
|
|
unlistener.value = null
|
|
}
|
|
}
|
|
+
|
|
+const showCurseForgeProfileModal = () => {
|
|
+ curseforgeProfileModal.value?.show()
|
|
+}
|
|
+
|
|
onUnmounted(() => {
|
|
if (unlistener.value) {
|
|
unlistener.value()
|
|
@@ -338,6 +353,7 @@ const game_versions = computed(() => {
|
|
})
|
|
|
|
const modal = ref(null)
|
|
+const curseforgeProfileModal = ref(null)
|
|
|
|
const check_valid = computed(() => {
|
|
return (
|
|
diff --git a/apps/app-frontend/src/helpers/import.js b/apps/app-frontend/src/helpers/import.js
|
|
index ba0bc4ff..d6bc9d38 100644
|
|
--- a/apps/app-frontend/src/helpers/import.js
|
|
+++ b/apps/app-frontend/src/helpers/import.js
|
|
@@ -61,3 +61,31 @@ export async function is_valid_importable_instance(instanceFolder, launcherType)
|
|
export async function get_default_launcher_path(launcherType) {
|
|
return await invoke('plugin:import|get_default_launcher_path', { launcherType })
|
|
}
|
|
+
|
|
+/// Fetch CurseForge profile metadata from profile code
|
|
+/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
|
|
+export async function fetch_curseforge_profile_metadata(profileCode) {
|
|
+ return await invoke('plugin:import|fetch_curseforge_profile_metadata', { profileCode })
|
|
+}
|
|
+
|
|
+/// Import a CurseForge profile from profile code
|
|
+/// eg: import_curseforge_profile("eSrNlKNo")
|
|
+export async function import_curseforge_profile(profileCode) {
|
|
+ try {
|
|
+ // First, fetch the profile metadata to get the actual name
|
|
+ const metadata = await fetch_curseforge_profile_metadata(profileCode)
|
|
+
|
|
+ // create a basic, empty instance using the actual profile name
|
|
+ const profilePath = await create(metadata.name, '1.19.4', 'vanilla', 'latest', null, true)
|
|
+
|
|
+ const result = await invoke('plugin:import|import_curseforge_profile', {
|
|
+ profilePath,
|
|
+ profileCode,
|
|
+ })
|
|
+
|
|
+ // Return the profile path for navigation
|
|
+ return { result, profilePath }
|
|
+ } catch (error) {
|
|
+ throw error
|
|
+ }
|
|
+}
|
|
diff --git a/apps/app/build.rs b/apps/app/build.rs
|
|
index 9d2b8789..aaf69482 100644
|
|
--- a/apps/app/build.rs
|
|
+++ b/apps/app/build.rs
|
|
@@ -51,6 +51,8 @@ fn main() {
|
|
"import",
|
|
InlinedPlugin::new()
|
|
.commands(&[
|
|
+ "fetch_curseforge_profile_metadata",
|
|
+ "import_curseforge_profile",
|
|
"get_importable_instances",
|
|
"import_instance",
|
|
"is_valid_importable_instance",
|
|
diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json
|
|
index c78dd971..26a0d64a 100644
|
|
--- a/apps/app/capabilities/plugins.json
|
|
+++ b/apps/app/capabilities/plugins.json
|
|
@@ -19,13 +19,10 @@
|
|
"window-state:default",
|
|
"window-state:allow-restore-state",
|
|
"window-state:allow-save-window-state",
|
|
-
|
|
+
|
|
{
|
|
"identifier": "http:default",
|
|
- "allow": [
|
|
- { "url": "https://modrinth.com/*" },
|
|
- { "url": "https://*.modrinth.com/*" }
|
|
- ]
|
|
+ "allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }]
|
|
},
|
|
|
|
"auth:default",
|
|
diff --git a/apps/app/src/api/import.rs b/apps/app/src/api/import.rs
|
|
index 0cab81cd..b642aa90 100644
|
|
--- a/apps/app/src/api/import.rs
|
|
+++ b/apps/app/src/api/import.rs
|
|
@@ -2,6 +2,11 @@ use std::path::PathBuf;
|
|
|
|
use crate::api::Result;
|
|
use theseus::pack::import::ImportLauncherType;
|
|
+use theseus::pack::import::curseforge_profile::{
|
|
+ CurseForgeProfileMetadata,
|
|
+ fetch_curseforge_profile_metadata as fetch_cf_metadata,
|
|
+ import_curseforge_profile as import_cf_profile,
|
|
+};
|
|
|
|
use theseus::pack::import;
|
|
|
|
@@ -12,6 +17,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|
import_instance,
|
|
is_valid_importable_instance,
|
|
get_default_launcher_path,
|
|
+ fetch_curseforge_profile_metadata,
|
|
+ import_curseforge_profile,
|
|
])
|
|
.build()
|
|
}
|
|
@@ -68,3 +75,24 @@ pub async fn get_default_launcher_path(
|
|
) -> Result<Option<PathBuf>> {
|
|
Ok(import::get_default_launcher_path(launcher_type))
|
|
}
|
|
+
|
|
+/// Fetch CurseForge profile metadata from profile code
|
|
+/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
|
|
+#[tauri::command]
|
|
+pub async fn fetch_curseforge_profile_metadata(
|
|
+ profile_code: String,
|
|
+) -> Result<CurseForgeProfileMetadata> {
|
|
+ Ok(fetch_cf_metadata(&profile_code).await?)
|
|
+}
|
|
+
|
|
+/// Import a CurseForge profile from profile code
|
|
+/// profile_path should be a blank profile for this purpose- if the function fails, it will be deleted
|
|
+/// eg: import_curseforge_profile("profile-path", "eSrNlKNo")
|
|
+#[tauri::command]
|
|
+pub async fn import_curseforge_profile(
|
|
+ profile_path: String,
|
|
+ profile_code: String,
|
|
+) -> Result<()> {
|
|
+ import_cf_profile(&profile_code, &profile_path).await?;
|
|
+ Ok(())
|
|
+}
|
|
diff --git a/packages/app-lib/src/api/pack/import/curseforge_profile.rs b/packages/app-lib/src/api/pack/import/curseforge_profile.rs
|
|
new file mode 100644
|
|
index 00000000..0c3527a6
|
|
--- /dev/null
|
|
+++ b/packages/app-lib/src/api/pack/import/curseforge_profile.rs
|
|
@@ -0,0 +1,604 @@
|
|
+use std::io::Cursor;
|
|
+
|
|
+use async_zip::base::read::seek::ZipFileReader;
|
|
+use serde::{Deserialize, Serialize};
|
|
+
|
|
+use crate::{
|
|
+ State,
|
|
+ event::{LoadingBarType, ProfilePayloadType},
|
|
+ prelude::ModLoader,
|
|
+ state::{LinkedData, ProfileInstallStage},
|
|
+ util::fetch::fetch,
|
|
+};
|
|
+
|
|
+use super::copy_dotminecraft;
|
|
+
|
|
+#[derive(Serialize, Deserialize, Debug)]
|
|
+#[serde(rename_all = "camelCase")]
|
|
+pub struct CurseForgeManifest {
|
|
+ pub minecraft: CurseForgeMinecraft,
|
|
+ pub manifest_type: String,
|
|
+ pub manifest_version: i32,
|
|
+ pub name: String,
|
|
+ pub version: String,
|
|
+ pub author: String,
|
|
+ pub files: Vec<CurseForgeFile>,
|
|
+ pub overrides: String,
|
|
+}
|
|
+
|
|
+#[derive(Serialize, Deserialize, Debug)]
|
|
+#[serde(rename_all = "camelCase")]
|
|
+pub struct CurseForgeMinecraft {
|
|
+ pub version: String,
|
|
+ pub mod_loaders: Vec<CurseForgeModLoader>,
|
|
+}
|
|
+
|
|
+#[derive(Serialize, Deserialize, Debug)]
|
|
+#[serde(rename_all = "camelCase")]
|
|
+pub struct CurseForgeModLoader {
|
|
+ pub id: String,
|
|
+ pub primary: bool,
|
|
+}
|
|
+
|
|
+#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
+pub struct CurseForgeFile {
|
|
+ #[serde(rename = "projectID")]
|
|
+ pub project_id: u32,
|
|
+ #[serde(rename = "fileID")]
|
|
+ pub file_id: u32,
|
|
+ pub required: bool,
|
|
+}
|
|
+
|
|
+#[derive(Serialize, Deserialize, Debug)]
|
|
+pub struct CurseForgeProfileMetadata {
|
|
+ pub name: String,
|
|
+ pub download_url: String,
|
|
+}
|
|
+
|
|
+/// Fetch CurseForge profile metadata from profile code
|
|
+pub async fn fetch_curseforge_profile_metadata(
|
|
+ profile_code: &str,
|
|
+) -> crate::Result<CurseForgeProfileMetadata> {
|
|
+ let state = State::get().await?;
|
|
+
|
|
+ // Make initial request to get redirect URL
|
|
+ let url = format!(
|
|
+ "https://api.curseforge.com/v1/shared-profile/{}",
|
|
+ profile_code
|
|
+ );
|
|
+
|
|
+ // Try to fetch the profile - the CurseForge API should redirect to the ZIP file
|
|
+ let response = fetch(&url, None, &state.fetch_semaphore, &state.pool).await;
|
|
+
|
|
+ let download_url = match response {
|
|
+ Ok(_bytes) => {
|
|
+ // If we get bytes back, use the original URL
|
|
+ url
|
|
+ }
|
|
+ Err(e) => {
|
|
+ // If we get an error, it might contain redirect information
|
|
+ let error_msg = format!("{:?}", e);
|
|
+ if let Some(redirect_start) =
|
|
+ error_msg.find("https://shared-profile-media.forgecdn.net/")
|
|
+ {
|
|
+ let redirect_end = error_msg[redirect_start..]
|
|
+ .find(' ')
|
|
+ .unwrap_or(error_msg.len() - redirect_start);
|
|
+ error_msg[redirect_start..redirect_start + redirect_end]
|
|
+ .to_string()
|
|
+ } else {
|
|
+ return Err(crate::ErrorKind::InputError(format!(
|
|
+ "Failed to fetch CurseForge profile metadata: {}",
|
|
+ e
|
|
+ ))
|
|
+ .into());
|
|
+ }
|
|
+ }
|
|
+ };
|
|
+
|
|
+ // Now fetch the ZIP file and extract the name from manifest.json
|
|
+ let zip_bytes =
|
|
+ fetch(&download_url, None, &state.fetch_semaphore, &state.pool).await?;
|
|
+
|
|
+ // Create a cursor for the ZIP data
|
|
+ let cursor = std::io::Cursor::new(zip_bytes);
|
|
+ let mut zip_reader =
|
|
+ ZipFileReader::with_tokio(cursor).await.map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to read profile ZIP: {}",
|
|
+ e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ // Find and extract manifest.json
|
|
+ let manifest_index = zip_reader
|
|
+ .file()
|
|
+ .entries()
|
|
+ .iter()
|
|
+ .position(|f| {
|
|
+ f.filename().as_str().unwrap_or_default() == "manifest.json"
|
|
+ })
|
|
+ .ok_or_else(|| {
|
|
+ crate::ErrorKind::InputError(
|
|
+ "No manifest.json found in profile".to_string(),
|
|
+ )
|
|
+ })?;
|
|
+
|
|
+ let mut manifest_content = String::new();
|
|
+ let mut reader = zip_reader
|
|
+ .reader_with_entry(manifest_index)
|
|
+ .await
|
|
+ .map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to read manifest.json: {}",
|
|
+ e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ reader.read_to_string_checked(&mut manifest_content).await?;
|
|
+
|
|
+ // Parse the manifest to get the actual name
|
|
+ let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
|
|
+
|
|
+ let profile_name = if manifest.name.is_empty() {
|
|
+ format!("CurseForge Profile {}", profile_code)
|
|
+ } else {
|
|
+ manifest.name.clone()
|
|
+ };
|
|
+
|
|
+ Ok(CurseForgeProfileMetadata {
|
|
+ name: profile_name,
|
|
+ download_url,
|
|
+ })
|
|
+}
|
|
+
|
|
+/// Import a CurseForge profile from profile code
|
|
+pub async fn import_curseforge_profile(
|
|
+ profile_code: &str,
|
|
+ profile_path: &str,
|
|
+) -> crate::Result<()> {
|
|
+ let state = State::get().await?;
|
|
+
|
|
+ // Initialize loading bar
|
|
+ let loading_bar = crate::event::emit::init_loading(
|
|
+ LoadingBarType::CurseForgeProfileDownload {
|
|
+ profile_name: profile_path.to_string(),
|
|
+ },
|
|
+ 100.0,
|
|
+ "Importing CurseForge profile...",
|
|
+ )
|
|
+ .await?;
|
|
+
|
|
+ // First, fetch the profile metadata to get the download URL
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 10.0,
|
|
+ Some("Fetching profile metadata..."),
|
|
+ )?;
|
|
+ let metadata = fetch_curseforge_profile_metadata(profile_code).await?;
|
|
+
|
|
+ // Download the profile ZIP file
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 5.0,
|
|
+ Some("Downloading profile ZIP..."),
|
|
+ )?;
|
|
+ let zip_bytes = fetch(
|
|
+ &metadata.download_url,
|
|
+ None,
|
|
+ &state.fetch_semaphore,
|
|
+ &state.pool,
|
|
+ )
|
|
+ .await?;
|
|
+
|
|
+ // Create a cursor for the ZIP data
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 5.0,
|
|
+ Some("Extracting ZIP contents..."),
|
|
+ )?;
|
|
+ let cursor = Cursor::new(zip_bytes);
|
|
+ let mut zip_reader =
|
|
+ ZipFileReader::with_tokio(cursor).await.map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to read profile ZIP: {}",
|
|
+ e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ // Find and extract manifest.json
|
|
+ let manifest_index = zip_reader
|
|
+ .file()
|
|
+ .entries()
|
|
+ .iter()
|
|
+ .position(|f| {
|
|
+ f.filename().as_str().unwrap_or_default() == "manifest.json"
|
|
+ })
|
|
+ .ok_or_else(|| {
|
|
+ crate::ErrorKind::InputError(
|
|
+ "No manifest.json found in profile".to_string(),
|
|
+ )
|
|
+ })?;
|
|
+
|
|
+ let mut manifest_content = String::new();
|
|
+ let mut reader = zip_reader
|
|
+ .reader_with_entry(manifest_index)
|
|
+ .await
|
|
+ .map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to read manifest.json: {}",
|
|
+ e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ reader.read_to_string_checked(&mut manifest_content).await?;
|
|
+
|
|
+ // Parse the manifest
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 5.0,
|
|
+ Some("Parsing profile manifest..."),
|
|
+ )?;
|
|
+ let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
|
|
+
|
|
+ // Determine modloader and version
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 5.0,
|
|
+ Some("Configuring profile..."),
|
|
+ )?;
|
|
+ let (mod_loader, loader_version) = if let Some(primary_loader) =
|
|
+ manifest.minecraft.mod_loaders.iter().find(|l| l.primary)
|
|
+ {
|
|
+ parse_modloader(&primary_loader.id)
|
|
+ } else if let Some(first_loader) = manifest.minecraft.mod_loaders.first() {
|
|
+ parse_modloader(&first_loader.id)
|
|
+ } else {
|
|
+ (ModLoader::Vanilla, None)
|
|
+ };
|
|
+
|
|
+ let game_version = manifest.minecraft.version.clone();
|
|
+
|
|
+ // Get appropriate loader version if needed
|
|
+ let final_loader_version = if mod_loader != ModLoader::Vanilla {
|
|
+ crate::launcher::get_loader_version_from_profile(
|
|
+ &game_version,
|
|
+ mod_loader,
|
|
+ loader_version.as_deref(),
|
|
+ )
|
|
+ .await?
|
|
+ } else {
|
|
+ None
|
|
+ };
|
|
+
|
|
+ // Set profile data
|
|
+ crate::api::profile::edit(profile_path, |prof| {
|
|
+ prof.name = if manifest.name.is_empty() {
|
|
+ format!("CurseForge Profile {}", profile_code)
|
|
+ } else {
|
|
+ manifest.name.clone()
|
|
+ };
|
|
+ prof.install_stage = ProfileInstallStage::PackInstalling;
|
|
+ prof.game_version = game_version.clone();
|
|
+ prof.loader_version = final_loader_version.clone().map(|x| x.id);
|
|
+ prof.loader = mod_loader;
|
|
+
|
|
+ // Set linked data for modpack management
|
|
+ prof.linked_data = Some(LinkedData {
|
|
+ project_id: String::new(),
|
|
+ version_id: String::new(),
|
|
+ locked: false,
|
|
+ });
|
|
+
|
|
+ async { Ok(()) }
|
|
+ })
|
|
+ .await?;
|
|
+
|
|
+ // Create a temporary directory to extract overrides
|
|
+ let temp_dir = state
|
|
+ .directories
|
|
+ .caches_dir()
|
|
+ .join(format!("curseforge_profile_{}", profile_code));
|
|
+ tokio::fs::create_dir_all(&temp_dir).await?;
|
|
+
|
|
+ // Extract overrides directory if it exists
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 10.0,
|
|
+ Some("Extracting profile files..."),
|
|
+ )?;
|
|
+ let overrides_dir = temp_dir.join(&manifest.overrides);
|
|
+ tokio::fs::create_dir_all(&overrides_dir).await?;
|
|
+
|
|
+ // Extract all files that are in the overrides directory
|
|
+ // First collect the entries we need to extract to avoid borrowing conflicts
|
|
+ let entries_to_extract: Vec<(usize, String)> = {
|
|
+ let zip_file = zip_reader.file();
|
|
+ zip_file
|
|
+ .entries()
|
|
+ .iter()
|
|
+ .enumerate()
|
|
+ .filter_map(|(index, entry)| {
|
|
+ let file_path = entry.filename().as_str().unwrap_or_default();
|
|
+ if file_path.starts_with(&format!("{}/", manifest.overrides)) {
|
|
+ Some((index, file_path.to_string()))
|
|
+ } else {
|
|
+ None
|
|
+ }
|
|
+ })
|
|
+ .collect()
|
|
+ };
|
|
+
|
|
+ // Now extract each file
|
|
+ for (index, file_path) in entries_to_extract {
|
|
+ let relative_path = file_path
|
|
+ .strip_prefix(&format!("{}/", manifest.overrides))
|
|
+ .unwrap();
|
|
+ let output_path = overrides_dir.join(relative_path);
|
|
+
|
|
+ // Create parent directories
|
|
+ if let Some(parent) = output_path.parent() {
|
|
+ tokio::fs::create_dir_all(parent).await?;
|
|
+ }
|
|
+
|
|
+ // Extract file
|
|
+ let mut reader =
|
|
+ zip_reader.reader_with_entry(index).await.map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to read file {}: {}",
|
|
+ file_path, e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ let mut file_content = Vec::new();
|
|
+ reader.read_to_end_checked(&mut file_content).await?;
|
|
+
|
|
+ tokio::fs::write(&output_path, file_content).await?;
|
|
+ }
|
|
+
|
|
+ // Copy overrides to profile
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 5.0,
|
|
+ Some("Copying profile files..."),
|
|
+ )?;
|
|
+ let _loading_bar = copy_dotminecraft(
|
|
+ profile_path,
|
|
+ overrides_dir,
|
|
+ &state.io_semaphore,
|
|
+ None,
|
|
+ )
|
|
+ .await?;
|
|
+
|
|
+ // Download and install mods from CurseForge
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 10.0,
|
|
+ Some("Downloading mods..."),
|
|
+ )?;
|
|
+ install_curseforge_mods(
|
|
+ &manifest.files,
|
|
+ profile_path,
|
|
+ &state,
|
|
+ &loading_bar,
|
|
+ )
|
|
+ .await?;
|
|
+
|
|
+ // Clean up temporary directory
|
|
+ tokio::fs::remove_dir_all(&temp_dir).await.ok();
|
|
+
|
|
+ // Install Minecraft if needed
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 20.0,
|
|
+ Some("Installing Minecraft..."),
|
|
+ )?;
|
|
+ if let Some(profile_val) = crate::api::profile::get(profile_path).await? {
|
|
+ crate::launcher::install_minecraft(
|
|
+ &profile_val,
|
|
+ Some(_loading_bar),
|
|
+ false,
|
|
+ )
|
|
+ .await?;
|
|
+ }
|
|
+
|
|
+ // Mark the profile as fully installed
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 20.0,
|
|
+ Some("Finalizing profile..."),
|
|
+ )?;
|
|
+ crate::api::profile::edit(profile_path, |prof| {
|
|
+ prof.install_stage = ProfileInstallStage::Installed;
|
|
+ async { Ok(()) }
|
|
+ })
|
|
+ .await?;
|
|
+
|
|
+ // Emit profile sync event to trigger file system watcher refresh
|
|
+ crate::event::emit::emit_profile(profile_path, ProfilePayloadType::Synced)
|
|
+ .await?;
|
|
+
|
|
+ // Complete the loading bar
|
|
+ crate::event::emit::emit_loading(
|
|
+ &loading_bar,
|
|
+ 5.0,
|
|
+ Some("Import completed!"),
|
|
+ )?;
|
|
+
|
|
+ Ok(())
|
|
+}
|
|
+
|
|
+/// Parse CurseForge modloader ID into ModLoader and version
|
|
+fn parse_modloader(id: &str) -> (ModLoader, Option<String>) {
|
|
+ if id.starts_with("forge-") {
|
|
+ let version = id.strip_prefix("forge-").unwrap_or("").to_string();
|
|
+ (ModLoader::Forge, Some(version))
|
|
+ } else if id.starts_with("fabric-") {
|
|
+ let version = id.strip_prefix("fabric-").unwrap_or("").to_string();
|
|
+ (ModLoader::Fabric, Some(version))
|
|
+ } else if id.starts_with("quilt-") {
|
|
+ let version = id.strip_prefix("quilt-").unwrap_or("").to_string();
|
|
+ (ModLoader::Quilt, Some(version))
|
|
+ } else if id.starts_with("neoforge-") {
|
|
+ let version = id.strip_prefix("neoforge-").unwrap_or("").to_string();
|
|
+ (ModLoader::NeoForge, Some(version))
|
|
+ } else {
|
|
+ (ModLoader::Vanilla, None)
|
|
+ }
|
|
+}
|
|
+
|
|
+/// Install mods from CurseForge files list
|
|
+async fn install_curseforge_mods(
|
|
+ files: &[CurseForgeFile],
|
|
+ profile_path: &str,
|
|
+ state: &State,
|
|
+ loading_bar: &crate::event::LoadingBarId,
|
|
+) -> crate::Result<()> {
|
|
+ if files.is_empty() {
|
|
+ return Ok(());
|
|
+ }
|
|
+
|
|
+ let num_files = files.len();
|
|
+ tracing::info!("Installing {} CurseForge mods", num_files);
|
|
+
|
|
+ // Download mods sequentially to track progress properly
|
|
+ for (index, file) in files.iter().enumerate() {
|
|
+ // Update progress message with current mod
|
|
+ let progress_message =
|
|
+ format!("Downloading mod {} of {}", index + 1, num_files);
|
|
+ crate::event::emit::emit_loading(
|
|
+ loading_bar,
|
|
+ 0.0, // Don't increment here, just update message
|
|
+ Some(&progress_message),
|
|
+ )?;
|
|
+
|
|
+ download_curseforge_mod(file, profile_path, state).await?;
|
|
+
|
|
+ // Emit progress for each downloaded mod (20% total for mods, divided by number of mods)
|
|
+ let mod_progress = 20.0 / num_files as f64;
|
|
+ crate::event::emit::emit_loading(
|
|
+ loading_bar,
|
|
+ mod_progress,
|
|
+ Some(&format!("Downloaded mod {} of {}", index + 1, num_files)),
|
|
+ )?;
|
|
+ }
|
|
+
|
|
+ Ok(())
|
|
+}
|
|
+
|
|
+/// Download a single mod from CurseForge
|
|
+async fn download_curseforge_mod(
|
|
+ file: &CurseForgeFile,
|
|
+ profile_path: &str,
|
|
+ _state: &State,
|
|
+) -> crate::Result<()> {
|
|
+ // Log the download attempt
|
|
+ tracing::info!(
|
|
+ "Downloading CurseForge mod: project_id={}, file_id={}",
|
|
+ file.project_id,
|
|
+ file.file_id
|
|
+ );
|
|
+
|
|
+ // Get profile path and create mods directory first
|
|
+ let profile_full_path =
|
|
+ crate::api::profile::get_full_path(profile_path).await?;
|
|
+ let mods_dir = profile_full_path.join("mods");
|
|
+ tokio::fs::create_dir_all(&mods_dir).await?;
|
|
+
|
|
+ // First, get the file metadata to get the correct filename
|
|
+ let metadata_url = format!(
|
|
+ "https://www.curseforge.com/api/v1/mods/{}/files/{}",
|
|
+ file.project_id, file.file_id
|
|
+ );
|
|
+
|
|
+ tracing::info!("Fetching metadata from: {}", metadata_url);
|
|
+
|
|
+ let client = reqwest::Client::new();
|
|
+ let metadata_response =
|
|
+ client.get(&metadata_url).send().await.map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to fetch metadata for mod {}/{}: {}",
|
|
+ file.project_id, file.file_id, e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ if !metadata_response.status().is_success() {
|
|
+ return Err(crate::ErrorKind::InputError(format!(
|
|
+ "HTTP error fetching metadata for mod {}/{}: {}",
|
|
+ file.project_id,
|
|
+ file.file_id,
|
|
+ metadata_response.status()
|
|
+ ))
|
|
+ .into());
|
|
+ }
|
|
+
|
|
+ // Parse the metadata JSON to get the filename
|
|
+ let metadata_json: serde_json::Value =
|
|
+ metadata_response.json().await.map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to parse metadata JSON for mod {}/{}: {}",
|
|
+ file.project_id, file.file_id, e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ let original_filename = metadata_json
|
|
+ .get("data")
|
|
+ .and_then(|data| data.get("fileName"))
|
|
+ .and_then(|name| name.as_str())
|
|
+ .map(|s| s.to_string())
|
|
+ .unwrap_or_else(|| {
|
|
+ // Fallback to the old format if API response is unexpected
|
|
+ format!("mod_{}_{}.jar", file.project_id, file.file_id)
|
|
+ });
|
|
+
|
|
+ tracing::info!("Original filename: {}", original_filename);
|
|
+
|
|
+ // Now download the mod using the direct download URL
|
|
+ let download_url = format!(
|
|
+ "https://www.curseforge.com/api/v1/mods/{}/files/{}/download",
|
|
+ file.project_id, file.file_id
|
|
+ );
|
|
+
|
|
+ tracing::info!("Downloading from: {}", download_url);
|
|
+
|
|
+ let response = client.get(&download_url).send().await.map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to download mod {}/{}: {}",
|
|
+ file.project_id, file.file_id, e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ if !response.status().is_success() {
|
|
+ return Err(crate::ErrorKind::InputError(format!(
|
|
+ "HTTP error downloading mod {}/{}: {}",
|
|
+ file.project_id,
|
|
+ file.file_id,
|
|
+ response.status()
|
|
+ ))
|
|
+ .into());
|
|
+ }
|
|
+
|
|
+ // Write the file with its original name
|
|
+ let final_path = mods_dir.join(&original_filename);
|
|
+ let bytes = response.bytes().await.map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to read response bytes for mod {}/{}: {}",
|
|
+ file.project_id, file.file_id, e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ tokio::fs::write(&final_path, &bytes).await.map_err(|e| {
|
|
+ crate::ErrorKind::InputError(format!(
|
|
+ "Failed to write mod file {:?}: {}",
|
|
+ final_path, e
|
|
+ ))
|
|
+ })?;
|
|
+
|
|
+ tracing::info!(
|
|
+ "Successfully downloaded mod: {} ({} bytes)",
|
|
+ original_filename,
|
|
+ bytes.len()
|
|
+ );
|
|
+
|
|
+ Ok(())
|
|
+}
|
|
diff --git a/packages/app-lib/src/api/pack/import/mod.rs b/packages/app-lib/src/api/pack/import/mod.rs
|
|
index 1f79d5e5..ff6e91b2 100644
|
|
--- a/packages/app-lib/src/api/pack/import/mod.rs
|
|
+++ b/packages/app-lib/src/api/pack/import/mod.rs
|
|
@@ -19,6 +19,7 @@ use crate::{
|
|
|
|
pub mod atlauncher;
|
|
pub mod curseforge;
|
|
+pub mod curseforge_profile;
|
|
pub mod gdlauncher;
|
|
pub mod mmc;
|
|
|
|
diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs
|
|
index 92b7b53f..f635da47 100644
|
|
--- a/packages/app-lib/src/event/mod.rs
|
|
+++ b/packages/app-lib/src/event/mod.rs
|
|
@@ -176,6 +176,9 @@ pub enum LoadingBarType {
|
|
import_location: PathBuf,
|
|
profile_name: String,
|
|
},
|
|
+ CurseForgeProfileDownload {
|
|
+ profile_name: String,
|
|
+ },
|
|
CheckingForUpdates,
|
|
LauncherUpdate {
|
|
version: String,
|