Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Deploy telemetry dashboard / deploy (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import type {
|
|
AbstractPopupNotificationManager,
|
|
AbstractWebNotificationManager,
|
|
CreationFlowContextValue,
|
|
CreationFlowModal,
|
|
SymlinkMethodChoice,
|
|
} from '@modrinth/ui'
|
|
import { defineMessages, useVIntl } from '@modrinth/ui'
|
|
import { join } from '@tauri-apps/api/path'
|
|
import { inject, provide, ref, useTemplateRef } from 'vue'
|
|
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
|
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
|
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
|
|
import { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge'
|
|
import { install_job_listener } from '@/helpers/events.js'
|
|
import { import_instance } from '@/helpers/import.js'
|
|
import {
|
|
type CreatePackLocation,
|
|
install_create_instance,
|
|
install_create_modpack_instance,
|
|
install_get_modpack_preview,
|
|
type InstallJobSnapshot,
|
|
wait_for_install_job,
|
|
} from '@/helpers/install'
|
|
import { check_symlink_capability, list } from '@/helpers/instance'
|
|
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata.js'
|
|
import type { InstanceLoader } from '@/helpers/types'
|
|
|
|
const symlinkMessages = defineMessages({
|
|
unsupportedTitle: {
|
|
id: 'app.symlink-capability.unsupported.title',
|
|
defaultMessage: 'Shared instances are unavailable',
|
|
},
|
|
unsupportedBody: {
|
|
id: 'app.symlink-capability.unsupported',
|
|
defaultMessage: 'This system does not support creating symbolic links.',
|
|
},
|
|
})
|
|
|
|
const modpackMessages = defineMessages({
|
|
installing: {
|
|
id: 'app.drop.modpack-installing',
|
|
defaultMessage: 'Installing modpack...',
|
|
},
|
|
installed: {
|
|
id: 'app.drop.modpack-installed-success',
|
|
defaultMessage: 'Modpack installed successfully',
|
|
},
|
|
installingFile: {
|
|
id: 'app.drop.installing-file',
|
|
defaultMessage: 'Installing {name}...',
|
|
},
|
|
unknownFileType: {
|
|
id: 'app.drop.unknown-force-analysis-title',
|
|
defaultMessage: 'Unable to identify file type',
|
|
},
|
|
unknownFileTypeText: {
|
|
id: 'app.drop.unknown-force-analysis-text',
|
|
defaultMessage:
|
|
'This archive needs to be extracted and deeply analyzed to determine its content type. This may take a while. Force analysis?',
|
|
},
|
|
forceAnalysis: {
|
|
id: 'app.drop.unknown-force-analysis-button',
|
|
defaultMessage: 'Force analysis',
|
|
},
|
|
analyzing: {
|
|
id: 'app.drop.unknown-force-analyzing',
|
|
defaultMessage: 'Force analyzing archive...',
|
|
},
|
|
couldNotIdentify: {
|
|
id: 'app.drop.unknown-force-analysis-failed-title',
|
|
defaultMessage: 'Analysis failed',
|
|
},
|
|
couldNotIdentifyText: {
|
|
id: 'app.drop.unknown-force-analysis-failed-text',
|
|
defaultMessage: 'Could not identify the file type even after deep analysis.',
|
|
},
|
|
unexpectedType: {
|
|
id: 'app.drop.unexpected-type',
|
|
defaultMessage: 'Unexpected type: {type}',
|
|
},
|
|
})
|
|
|
|
const OPTIFABRIC_CURSEFORGE_PROJECT_ID = 322385
|
|
|
|
export function setupCreationModal(
|
|
notificationManager: AbstractWebNotificationManager,
|
|
_popupNotificationManager: AbstractPopupNotificationManager,
|
|
) {
|
|
const { formatMessage } = useVIntl()
|
|
const { handleError } = notificationManager
|
|
const router = useRouter()
|
|
|
|
const installationModal =
|
|
useTemplateRef<ComponentExposed<typeof CreationFlowModal>>('installationModal')
|
|
const unknownPackWarningModal =
|
|
useTemplateRef<InstanceType<typeof UnknownPackWarningModal>>('unknownPackWarningModal')
|
|
const modpackAlreadyInstalledModal = ref<InstanceType<typeof ModpackAlreadyInstalledModal>>()
|
|
|
|
function setModpackAlreadyInstalledModal(
|
|
modal: InstanceType<typeof ModpackAlreadyInstalledModal>,
|
|
) {
|
|
modpackAlreadyInstalledModal.value = modal
|
|
}
|
|
|
|
async function fetchExistingInstanceNames(): Promise<string[]> {
|
|
const instances = await list().catch(handleError)
|
|
return instances?.map((i) => i.name) ?? []
|
|
}
|
|
|
|
provide('showCreationModal', () => {
|
|
installationModal.value?.show()
|
|
})
|
|
|
|
provide(
|
|
'showCreationModalWithOptions',
|
|
(options?: {
|
|
skipSetupType?: boolean
|
|
initialMode?: 'custom' | 'import'
|
|
onBack?: () => void
|
|
}) => {
|
|
installationModal.value?.show(options)
|
|
},
|
|
)
|
|
|
|
async function proceedWithModpackCreation(
|
|
projectId: string,
|
|
versionId: string,
|
|
name: string,
|
|
iconUrl?: string,
|
|
) {
|
|
await install_create_modpack_instance(
|
|
{
|
|
type: 'fromVersionId',
|
|
project_id: projectId,
|
|
version_id: versionId,
|
|
title: name,
|
|
icon_url: iconUrl,
|
|
},
|
|
{ name },
|
|
).catch(handleError)
|
|
}
|
|
|
|
async function handleCreate(config: CreationFlowContextValue) {
|
|
try {
|
|
installationModal.value?.hide()
|
|
|
|
if (config.isImportMode.value) {
|
|
// Collect all instances to import
|
|
const instanceEntries: Array<{
|
|
launcherType: string
|
|
launcherName: string
|
|
path: string
|
|
instanceName: string
|
|
instancePath: string
|
|
}> = []
|
|
for (const [launcherName, instanceSet] of Object.entries(
|
|
config.importSelectedInstances.value,
|
|
)) {
|
|
const launcher = config.importLaunchers.value.find((l) => l.name === launcherName)
|
|
if (!launcher || instanceSet.size === 0) continue
|
|
for (const name of instanceSet) {
|
|
const instanceData = launcher.instances.find((i) => i.name === name)
|
|
instanceEntries.push({
|
|
launcherType: launcher.launcherType ?? launcher.name,
|
|
launcherName: launcher.name,
|
|
path: launcher.path,
|
|
instanceName: name,
|
|
instancePath: instanceData?.path ?? '',
|
|
})
|
|
}
|
|
}
|
|
|
|
if (instanceEntries.length === 0) return
|
|
|
|
// Show SymlinkMethodCards for user to choose copy vs symlink
|
|
const capability = await check_symlink_capability()
|
|
if (capability === 'unsupported') {
|
|
notificationManager.addNotification({
|
|
type: 'error',
|
|
title: formatMessage(symlinkMessages.unsupportedTitle),
|
|
text: formatMessage(symlinkMessages.unsupportedBody),
|
|
})
|
|
return
|
|
}
|
|
|
|
const chooseImportMethod: (options: {
|
|
instanceNames: string[]
|
|
symlinkCapable: 'supported' | 'requires_admin' | 'unsupported'
|
|
}) => Promise<SymlinkMethodChoice[]> = inject('chooseImportMethod')!
|
|
|
|
const choices = await chooseImportMethod({
|
|
instanceNames: instanceEntries.map((e) => e.instanceName),
|
|
symlinkCapable: capability,
|
|
})
|
|
|
|
if (choices.length === 0) return
|
|
|
|
const choiceByInstanceName = new Map(choices.map((choice) => [choice.instanceName, choice]))
|
|
|
|
for (const entry of instanceEntries) {
|
|
const choice = choiceByInstanceName.get(entry.instanceName)
|
|
try {
|
|
const job = await import_instance(
|
|
entry.launcherType,
|
|
entry.path,
|
|
entry.instanceName,
|
|
choice?.symlink ?? false,
|
|
entry.instancePath,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
choice?.gameDirOverride ?? null,
|
|
)
|
|
await wait_for_install_job(job.job_id)
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (config.modpackSelection.value) {
|
|
const { projectId, versionId, name, iconUrl } = config.modpackSelection.value
|
|
await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
|
|
return
|
|
}
|
|
|
|
if (config.modpackFilePath.value) {
|
|
// Fallback: called when modpack is imported via the creation flow
|
|
// (not via onImportFileReceived, which has its own install path).
|
|
const splitPath = config.modpackFilePath.value.split(/[\\/]/)
|
|
const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value
|
|
await installModpackFromPath(config.modpackFilePath.value, fileName)
|
|
return
|
|
}
|
|
|
|
// Custom/vanilla setup
|
|
const loader = config.hideLoaderChips.value
|
|
? 'vanilla'
|
|
: (config.selectedLoader.value ?? 'vanilla')
|
|
const loaderVersion = config.hideLoaderVersion.value
|
|
? null
|
|
: (config.selectedLoaderVersion.value ?? config.loaderVersionType.value)
|
|
const iconPath = config.instanceIconPath.value ?? null
|
|
const name = config.instanceName.value.trim() || config.autoInstanceName.value
|
|
// Game directory: `gameDirOverride` holds the picked `.minecraft`
|
|
// root. Builtin keeps the managed folder (null); external resolves to
|
|
// `<root>/versions/<name>` when version-isolated, or the `.minecraft`
|
|
// root itself when not.
|
|
const mode = config.gameDirOverrideMode.value
|
|
const gameRoot = config.gameDirOverride.value ?? null
|
|
const gameDirOverride =
|
|
mode === 'builtin'
|
|
? null
|
|
: gameRoot
|
|
? mode === 'isolated'
|
|
? await join(gameRoot, 'versions', name)
|
|
: gameRoot
|
|
: null
|
|
|
|
await install_create_instance({
|
|
name,
|
|
gameVersion: config.selectedGameVersion.value!,
|
|
loader: loader as InstanceLoader,
|
|
loaderVersion,
|
|
adjuncts: config.selectedAdjuncts.value.map((kind) => ({
|
|
instanceId: '',
|
|
kind,
|
|
version: null,
|
|
role: 'adjunct',
|
|
})),
|
|
iconPath,
|
|
gameDirOverride,
|
|
}).catch(handleError)
|
|
|
|
} catch (err) {
|
|
handleError(err as Error)
|
|
}
|
|
}
|
|
|
|
const pendingModpackCreation = ref<{
|
|
projectId: string
|
|
versionId: string
|
|
name: string
|
|
iconUrl?: string
|
|
} | null>(null)
|
|
|
|
async function doInstallModpackFile(
|
|
location: CreatePackLocation,
|
|
options: { autoCloseMs?: number | null; existingNotify?: { id: number } } = {},
|
|
) {
|
|
const existingNotify = options.existingNotify
|
|
const installingNotify =
|
|
existingNotify ??
|
|
notificationManager.addNotification({
|
|
title: formatMessage(modpackMessages.installing),
|
|
type: 'info',
|
|
autoCloseMs: options.autoCloseMs ?? 1000 * 10,
|
|
})
|
|
|
|
const job = await install_create_modpack_instance(location).catch((e) => {
|
|
notificationManager.removeNotification(installingNotify.id)
|
|
handleError(e)
|
|
return null
|
|
})
|
|
if (!job) return
|
|
|
|
// Single-use listener that auto-cleans up when the job reaches a terminal state
|
|
const unlisten = await install_job_listener((updatedJob: InstallJobSnapshot) => {
|
|
if (updatedJob.job_id !== job.job_id) return
|
|
|
|
if (updatedJob.status === 'succeeded') {
|
|
notificationManager.removeNotification(installingNotify.id)
|
|
notificationManager.addNotification({
|
|
title: formatMessage(modpackMessages.installed),
|
|
type: 'success',
|
|
})
|
|
unlisten()
|
|
} else if (['failed', 'canceled', 'interrupted'].includes(updatedJob.status)) {
|
|
notificationManager.removeNotification(installingNotify.id)
|
|
unlisten()
|
|
}
|
|
})
|
|
}
|
|
|
|
async function handleModpackDuplicateCreateAnyway() {
|
|
if (!pendingModpackCreation.value) return
|
|
const { projectId, versionId, name, iconUrl } = pendingModpackCreation.value
|
|
pendingModpackCreation.value = null
|
|
await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
|
|
}
|
|
|
|
function handleModpackDuplicateGoToInstance(instanceId: string) {
|
|
pendingModpackCreation.value = null
|
|
router.push(`/instance/${encodeURIComponent(instanceId)}/`)
|
|
}
|
|
|
|
function handleBrowseModpacks() {
|
|
installationModal.value?.hide()
|
|
router.push('/browse/modpack')
|
|
}
|
|
|
|
async function searchModpacks(query: string, limit: number = 10) {
|
|
const params = [`facets=[["project_type:modpack"]]`, `limit=${limit}`]
|
|
if (query) {
|
|
params.push(`query=${encodeURIComponent(query)}`)
|
|
}
|
|
const raw = await get_search_results(`?${params.join('&')}`)
|
|
if (raw?.result) return raw.result
|
|
return { hits: [], offset: 0, limit, total_hits: 0 }
|
|
}
|
|
|
|
async function getProjectVersions(projectId: string) {
|
|
const versions = await get_project_versions(projectId, 'must_revalidate')
|
|
return versions ?? []
|
|
}
|
|
|
|
async function hasCompatibleOptiFabric(gameVersion: string) {
|
|
const response = await getCurseForgeFiles(OPTIFABRIC_CURSEFORGE_PROJECT_ID, {
|
|
index: 0,
|
|
pageSize: 50,
|
|
})
|
|
return hasCompatibleCurseForgeFile(response.files, gameVersion)
|
|
}
|
|
|
|
let _currentFlowCtx: CreationFlowContextValue | null = null
|
|
|
|
/** Install a modpack file with continuous feedback notifications. */
|
|
async function installModpackFromPath(
|
|
filePath: string,
|
|
fileName: string,
|
|
options: { persistUntilDone?: boolean } = {},
|
|
) {
|
|
const persistUntilDone = options.persistUntilDone === true
|
|
const currentNotify = notificationManager.addNotification({
|
|
title: formatMessage(modpackMessages.installingFile, { name: fileName }),
|
|
type: 'info',
|
|
autoCloseMs: persistUntilDone ? null : 1000 * 10,
|
|
})
|
|
|
|
const location: CreatePackLocation = { type: 'fromFile', path: filePath }
|
|
|
|
try {
|
|
const isMrpack = fileName?.toLowerCase().endsWith('.mrpack')
|
|
|
|
if (!isMrpack) {
|
|
// .zip needs preview to determine manifest
|
|
const preview = await install_get_modpack_preview(location).catch((e) => {
|
|
notificationManager.removeNotification(currentNotify.id)
|
|
handleError(e)
|
|
return null
|
|
})
|
|
if (!preview) return
|
|
|
|
if (preview.unknownFile) {
|
|
notificationManager.removeNotification(currentNotify.id)
|
|
unknownPackWarningModal.value?.show(async () => {
|
|
if (persistUntilDone) {
|
|
const installingNotify = notificationManager.addNotification({
|
|
title: formatMessage(modpackMessages.installing),
|
|
type: 'info',
|
|
autoCloseMs: null,
|
|
})
|
|
await doInstallModpackFile(location, { existingNotify: installingNotify })
|
|
} else {
|
|
await doInstallModpackFile(location)
|
|
}
|
|
}, fileName)
|
|
return
|
|
}
|
|
}
|
|
|
|
if (persistUntilDone) {
|
|
await doInstallModpackFile(location, { existingNotify: currentNotify })
|
|
} else {
|
|
await doInstallModpackFile(location)
|
|
}
|
|
} catch (e) {
|
|
notificationManager.removeNotification(currentNotify?.id)
|
|
handleError(e as Error)
|
|
}
|
|
}
|
|
|
|
provide('setCreationFlowCtx', (ctx: CreationFlowContextValue) => {
|
|
_currentFlowCtx = ctx
|
|
})
|
|
|
|
return {
|
|
installationModal,
|
|
unknownPackWarningModal,
|
|
fetchExistingInstanceNames,
|
|
handleCreate,
|
|
handleBrowseModpacks,
|
|
searchModpacks,
|
|
getProjectVersions,
|
|
hasCompatibleOptiFabric,
|
|
getLoaderManifest,
|
|
installModpackFromPath,
|
|
setModpackAlreadyInstalledModal,
|
|
handleModpackDuplicateCreateAnyway,
|
|
handleModpackDuplicateGoToInstance,
|
|
}
|
|
}
|