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
2482 lines
74 KiB
TypeScript
2482 lines
74 KiB
TypeScript
/**
|
|
* Composable encapsulating the entire drag-and-drop import system.
|
|
*
|
|
* Handles single-file drops, batch drops, launcher instance imports,
|
|
* modpack installs, content installation, and all related UI flows.
|
|
*/
|
|
|
|
import type {
|
|
BatchDropGroup,
|
|
BatchDropItem,
|
|
BatchDropPhase,
|
|
ClassificationResult,
|
|
SymlinkMethodChoice,
|
|
} from '@modrinth/ui'
|
|
import { useDebugLogger, useGlobalDrop, useInstanceContext, useVIntl } from '@modrinth/ui'
|
|
import { computed, type ComputedRef, nextTick, ref } from 'vue'
|
|
import type { Router } from 'vue-router'
|
|
import { join } from '@tauri-apps/api/path'
|
|
|
|
import {
|
|
classifyDroppedItem,
|
|
classifyDroppedItemWithExtraction,
|
|
detectFileLock,
|
|
extractModMetadata,
|
|
extractZipToTemp,
|
|
lookupModHash,
|
|
type ModrinthLookupResult,
|
|
removeTempDir,
|
|
scanLauncherInstances,
|
|
type ScanResult,
|
|
} from '@/helpers/drop'
|
|
import { import_instance } from '@/helpers/import.js'
|
|
import { wait_for_install_job } from '@/helpers/install'
|
|
import {
|
|
add_project_from_path,
|
|
check_symlink_capability,
|
|
get as getInstance,
|
|
import_world_save,
|
|
install_datapack_to_world,
|
|
list as listInstances,
|
|
} from '@/helpers/instance'
|
|
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
|
import { areLoadersCompatible, isVersionInRange } from '@/helpers/version-compatibility'
|
|
import type { AppNotificationManager } from '@/providers/app-notifications'
|
|
import type { AppPopupNotificationManager } from '@/providers/app-popup-notifications'
|
|
import type { ContentInstallContext } from '@/providers/content-install'
|
|
|
|
// Re-export types for external use
|
|
export type { ClassificationResult, ModrinthLookupResult, ScanResult }
|
|
|
|
export type ContentFileProjectType =
|
|
| 'mod'
|
|
| 'resourcepack'
|
|
| 'datapack'
|
|
| 'shaderpack'
|
|
| 'schematic'
|
|
|
|
export interface PendingInstall {
|
|
type: string
|
|
filePath: string
|
|
innerBase?: string
|
|
}
|
|
|
|
export interface PendingDropIncompatibility {
|
|
filePath: string
|
|
instId: string
|
|
type: string
|
|
instVersion: string | undefined
|
|
instLoader: string | undefined
|
|
meta: { name?: string; mod_id?: string } | null
|
|
modrinthLookup: ModrinthLookupResult | null
|
|
}
|
|
|
|
export interface SelectedInstance {
|
|
launcherType: string
|
|
basePath: string
|
|
name: string
|
|
path: string
|
|
compatibleMode?: boolean
|
|
versionPath?: string
|
|
}
|
|
|
|
export interface ImportContext {
|
|
launcherType: string
|
|
basePath: string
|
|
}
|
|
|
|
export interface BatchTargetInstanceInfo {
|
|
id: string
|
|
name: string
|
|
iconUrl?: string | null
|
|
gameVersion?: string | null
|
|
loader?: string | null
|
|
}
|
|
|
|
export interface DropImportOptions {
|
|
/** Notification manager instance */
|
|
notificationManager: AppNotificationManager
|
|
/** Popup notification manager instance */
|
|
popupNotificationManager: AppPopupNotificationManager
|
|
/** Install modpack from path function */
|
|
installModpackFromPath: (
|
|
path: string,
|
|
name: string,
|
|
options: { persistUntilDone: boolean },
|
|
) => Promise<void>
|
|
/** Content install provider functions */
|
|
contentInstall: ContentInstallContext
|
|
/** File drop handler from providers */
|
|
fileDrop: (paths: string[]) => void
|
|
/** Whether currently on skins page */
|
|
onSkinsPage: ComputedRef<boolean>
|
|
/** Whether currently on schematic workshop page */
|
|
onSchematicWorkshopPage: ComputedRef<boolean>
|
|
/** Check if path is a schematic file */
|
|
isSchematicFile: (path: string) => boolean
|
|
/** Route to push */
|
|
router: Router
|
|
}
|
|
|
|
/**
|
|
* Composable that manages the entire drag-and-drop import system.
|
|
*
|
|
* Usage:
|
|
* ```ts
|
|
* const dropImport = useDropImport({
|
|
* notificationManager,
|
|
* popupNotificationManager,
|
|
* handleError,
|
|
* installModpackFromPath,
|
|
* contentInstall,
|
|
* fileDrop,
|
|
* onSkinsPage,
|
|
* onSchematicWorkshopPage,
|
|
* isSchematicFile,
|
|
* router,
|
|
* route,
|
|
* })
|
|
* ```
|
|
*/
|
|
export function useDropImport(options: DropImportOptions) {
|
|
const {
|
|
notificationManager,
|
|
popupNotificationManager,
|
|
installModpackFromPath,
|
|
contentInstall,
|
|
fileDrop,
|
|
onSkinsPage,
|
|
onSchematicWorkshopPage,
|
|
isSchematicFile,
|
|
router,
|
|
} = options
|
|
|
|
const { formatMessage } = useVIntl()
|
|
const dropDebug = useDebugLogger('DropFlow')
|
|
const { addNotification } = notificationManager
|
|
const { addPopupNotification } = popupNotificationManager
|
|
|
|
// ── Instance context ──────────────────────────────────────────────────
|
|
const { isInInstance, instanceId } = useInstanceContext()
|
|
|
|
// ── Modal refs (to be bound externally) ──────────────────────────────
|
|
const confirmDropModal = ref<{ show: () => void; hide: () => void } | null>(null)
|
|
const genericInstallModal = ref<{
|
|
show: (options: {
|
|
contentType: string
|
|
fileName: string
|
|
instances: BatchTargetInstanceInfo[]
|
|
}) => void
|
|
hide: () => void
|
|
} | null>(null)
|
|
const launcherImportModal = ref<{
|
|
show: (results: ScanResult[]) => void
|
|
hide: () => void
|
|
} | null>(null)
|
|
const symlinkCardsModal = ref<{
|
|
show: (options: {
|
|
instances: Array<{
|
|
name: string
|
|
path?: string
|
|
launcherType?: string
|
|
basePath?: string
|
|
compatibleMode?: boolean
|
|
}>
|
|
symlinkCapable: string
|
|
}) => void
|
|
hide: () => void
|
|
} | null>(null)
|
|
const dataPackWorldModal = ref<{
|
|
show: (instanceId?: string) => void
|
|
} | null>(null)
|
|
const compatibleModeConfirmModal = ref<{ show: () => void } | null>(null)
|
|
const incompatibilityWarningModal = ref<{ show: () => void } | null>(null)
|
|
|
|
// ── Content file project type map ────────────────────────────────────
|
|
const contentFileProjectTypeMap: Record<string, ContentFileProjectType | undefined> = {
|
|
mod: 'mod',
|
|
resource_pack: 'resourcepack',
|
|
data_pack: 'datapack',
|
|
shader_pack: 'shaderpack',
|
|
litematic: 'schematic',
|
|
schematic: 'schematic',
|
|
}
|
|
|
|
// ── Single-file drop state ───────────────────────────────────────────
|
|
const dropClassification = ref<ClassificationResult | null>(null)
|
|
const dropFileName = ref('')
|
|
const dropFilePath = ref('')
|
|
const lastDroppedPath = ref('')
|
|
const scanningInstances = ref(false)
|
|
const pendingInstall = ref<PendingInstall | null>(null)
|
|
const pendingDropIncompatibility = ref<PendingDropIncompatibility | null>(null)
|
|
const selectedInstances = ref<SelectedInstance[]>([])
|
|
const currentImportContext = ref<ImportContext | null>(null)
|
|
const compatibleModeResults = ref<ScanResult[] | null>(null)
|
|
const compatibleModeGameDir = ref<string | null>(null)
|
|
const compatibleModeLauncherType = ref<string>('Generic')
|
|
const launcherZipTempDir = ref<string | null>(null)
|
|
const dropProcessingNotificationId = ref<string | number | null>(null)
|
|
|
|
// ── Batch drop state ─────────────────────────────────────────────────
|
|
const batchPhase = ref<BatchDropPhase>('idle')
|
|
const batchActive = computed(() => batchPhase.value !== 'idle')
|
|
const batchItems = ref<BatchDropItem[]>([])
|
|
const batchOriginalCount = ref(0)
|
|
const batchScanDone = ref(0)
|
|
const batchGroups = ref<BatchDropGroup[]>([])
|
|
const batchCurrentGroup = ref<BatchDropGroup | null>(null)
|
|
const batchTargetInstances = ref<BatchTargetInstanceInfo[]>([])
|
|
const batchTargetInstanceId = ref('')
|
|
const batchWorldPath = ref('')
|
|
const batchTempDirs = ref<string[]>([])
|
|
|
|
let batchScanCancelled = false
|
|
let batchInstallCancelled = false
|
|
let batchConfirmIndex = 0
|
|
let batchSymlinkMode = false
|
|
let batchTargetPickMode = false
|
|
let batchWorldMode = false
|
|
let batchGroupMode = false
|
|
const batchGroupKey = ref(0)
|
|
const incompatWarningKey = ref(0)
|
|
let batchCompatResolve: ((installed: boolean) => void) | null = null
|
|
|
|
// ── Content install incompatibility state ─────────────────────────────
|
|
const contentInstallIncompatibilityWarningVersions = ref([])
|
|
const contentInstallIncompatibilityWarningCurrentGameVersion = ref('')
|
|
const contentInstallIncompatibilityWarningCurrentLoader = ref('')
|
|
const contentInstallIncompatibilityWarningProjectType = ref('')
|
|
const contentInstallIncompatibilityWarningProjectIconUrl = ref('')
|
|
const contentInstallIncompatibilityWarningProjectName = ref('')
|
|
const contentInstallIncompatibilityWarningMessage = ref('')
|
|
const contentInstallIncompatibilityWarningInstalling = ref(false)
|
|
|
|
// ── Symlink choice state ─────────────────────────────────────────────
|
|
let symlinkChoiceResolve: ((choices: SymlinkMethodChoice[]) => void) | null = null
|
|
|
|
// ── Messages ─────────────────────────────────────────────────────────
|
|
const messages = defineMessages({
|
|
dropOverlayTitle: {
|
|
id: 'app.drop.overlay-title',
|
|
defaultMessage: 'Drop to import',
|
|
},
|
|
dropOverlaySubtitle: {
|
|
id: 'app.drop.overlay-subtitle',
|
|
defaultMessage: 'Release to analyze',
|
|
},
|
|
dropProcessing: {
|
|
id: 'app.drop.processing',
|
|
defaultMessage: 'Processing {name}...',
|
|
},
|
|
dropMultipleFilesTitle: {
|
|
id: 'app.drop.error.multiple-files-title',
|
|
defaultMessage: 'Cannot import multiple files',
|
|
},
|
|
dropMultipleFilesText: {
|
|
id: 'app.drop.error.multiple-files-text',
|
|
defaultMessage: 'Please drop one file at a time.',
|
|
},
|
|
dropShortcutFailedTitle: {
|
|
id: 'app.drop.error.shortcut-title',
|
|
defaultMessage: 'Shortcut resolution failed',
|
|
},
|
|
dropShortcutFailedText: {
|
|
id: 'app.drop.error.shortcut-text',
|
|
defaultMessage: 'Could not resolve the shortcut target.',
|
|
},
|
|
dropUnknownTitle: {
|
|
id: 'app.drop.error.unknown-title',
|
|
defaultMessage: 'Unknown file type',
|
|
},
|
|
dropUnknownText: {
|
|
id: 'app.drop.error.unknown-text',
|
|
defaultMessage: 'Could not determine what kind of file this is.',
|
|
},
|
|
dropUnknownDepthText: {
|
|
id: 'app.drop.error.unknown-depth-text',
|
|
defaultMessage:
|
|
'The archive is nested too deeply to analyze. Unpack it to a folder and try again.',
|
|
},
|
|
dropUnknownEncryptedText: {
|
|
id: 'app.drop.error.unknown-encrypted-text',
|
|
defaultMessage: 'The archive contains encrypted files and cannot be analyzed.',
|
|
},
|
|
dropNestedUnpackTitle: {
|
|
id: 'app.drop.nested-unpack-title',
|
|
defaultMessage: 'Nested archives detected',
|
|
},
|
|
dropNestedUnpackText: {
|
|
id: 'app.drop.nested-unpack-text',
|
|
defaultMessage:
|
|
'This archive contains nested archives ({size}) that must be unpacked to analyze. This may take some time. Continue?',
|
|
},
|
|
dropNestedUnpackButton: {
|
|
id: 'app.drop.nested-unpack-button',
|
|
defaultMessage: 'Continue analysis',
|
|
},
|
|
dropErrorTitle: {
|
|
id: 'app.drop.error.title',
|
|
defaultMessage: 'Drop error',
|
|
},
|
|
dropWorldImportedTitle: {
|
|
id: 'app.drop.world-imported-title',
|
|
defaultMessage: 'World imported',
|
|
},
|
|
dropWorldImportedText: {
|
|
id: 'app.drop.world-imported-text',
|
|
defaultMessage: 'World save has been imported successfully.',
|
|
},
|
|
dropContentInstalledTitle: {
|
|
id: 'app.drop.content-installed-title',
|
|
defaultMessage: 'Content installed',
|
|
},
|
|
dropContentInstalledText: {
|
|
id: 'app.drop.content-installed-text',
|
|
defaultMessage: 'File has been installed to the instance.',
|
|
},
|
|
dropInstallFailedTitle: {
|
|
id: 'app.drop.install-failed-title',
|
|
defaultMessage: 'Installation failed',
|
|
},
|
|
dropInstanceImportedTitle: {
|
|
id: 'app.drop.instance-imported-title',
|
|
defaultMessage: 'Instance imported',
|
|
},
|
|
dropInstanceImportedText: {
|
|
id: 'app.drop.instance-imported-text',
|
|
defaultMessage: '{name} imported successfully.',
|
|
},
|
|
dropImportFailedTitle: {
|
|
id: 'app.drop.import-failed-title',
|
|
defaultMessage: 'Import failed',
|
|
},
|
|
dropImportFailedText: {
|
|
id: 'app.drop.import-failed-text',
|
|
defaultMessage: 'Failed to import {name}: {error}',
|
|
},
|
|
dropNoInstances: {
|
|
id: 'app.drop.no-instances',
|
|
defaultMessage: 'No instances found',
|
|
},
|
|
dropScanning: {
|
|
id: 'app.drop.scanning',
|
|
defaultMessage: 'Scanning for instances',
|
|
},
|
|
dropScanFailed: {
|
|
id: 'app.drop.scan-failed',
|
|
defaultMessage: 'Failed to scan for instances',
|
|
},
|
|
dropExtractFailed: {
|
|
id: 'app.drop.extract-failed',
|
|
defaultMessage: 'Failed to extract archive',
|
|
},
|
|
dropProcessFailedTitle: {
|
|
id: 'app.drop.process-failed-title',
|
|
defaultMessage: 'Failed to process file',
|
|
},
|
|
dropTemporaryFileTitle: {
|
|
id: 'app.drop.temporary-file-title',
|
|
defaultMessage: 'Temporary file detected',
|
|
},
|
|
dropTemporaryFileText: {
|
|
id: 'app.drop.temporary-file-text',
|
|
defaultMessage:
|
|
'The file "{file}" appears to be a temporary copy. Try dragging the file from its original folder instead of from a browser, archive, or cloud storage.',
|
|
},
|
|
dropImportProgressTitle: {
|
|
id: 'app.drop.import-progress-title',
|
|
defaultMessage: 'Importing instances…',
|
|
},
|
|
dropImportProgressText: {
|
|
id: 'app.drop.import-progress-text',
|
|
defaultMessage: '{current} / {total} instances imported',
|
|
},
|
|
dropImportCompletedTitle: {
|
|
id: 'app.drop.import-completed-title',
|
|
defaultMessage: 'Import completed',
|
|
},
|
|
dropImportCompletedText: {
|
|
id: 'app.drop.import-completed-text',
|
|
defaultMessage: 'Successfully imported {count} instances',
|
|
},
|
|
dropImportCompletedPartialText: {
|
|
id: 'app.drop.import-completed-partial-text',
|
|
defaultMessage: 'Imported {completed} of {total} instances ({failed} failed)',
|
|
},
|
|
dropImportCancelledTitle: {
|
|
id: 'app.drop.batch.import-cancelled-title',
|
|
defaultMessage: 'Import cancelled',
|
|
},
|
|
dropImportCancelledText: {
|
|
id: 'app.drop.batch.import-cancelled-text',
|
|
defaultMessage: 'Nothing was imported.',
|
|
},
|
|
dropBatchNothingImportableTitle: {
|
|
id: 'app.drop.batch.nothing-importable-title',
|
|
defaultMessage: 'Nothing to import',
|
|
},
|
|
dropBatchNothingImportableText: {
|
|
id: 'app.drop.batch.nothing-importable-text',
|
|
defaultMessage: '{count, plural, one {# file} other {# files}} could not be recognized.',
|
|
},
|
|
dropBatchCompletedTitle: {
|
|
id: 'app.drop.batch.completed-title',
|
|
defaultMessage: 'Import finished',
|
|
},
|
|
dropBatchCompletedText: {
|
|
id: 'app.drop.batch.completed-text',
|
|
defaultMessage: 'Imported {succeeded} of {total} ({failed} failed, {skipped} skipped).',
|
|
},
|
|
dropBatchTargetLabel: {
|
|
id: 'app.drop.batch.target-label',
|
|
defaultMessage: 'Select target instance for this batch',
|
|
},
|
|
dropBatchGroupFileLabel: {
|
|
id: 'app.drop.batch.group-file-label',
|
|
defaultMessage: '{count, plural, one {# file} other {# files}}: {names}',
|
|
},
|
|
dropModpackInstallFailed: {
|
|
id: 'app.drop.modpack-install-failed',
|
|
defaultMessage: 'Failed to install modpack',
|
|
},
|
|
dropUnknownForceAnalysisTitle: {
|
|
id: 'app.drop.unknown-force-analysis-title',
|
|
defaultMessage: 'Unable to identify file type',
|
|
},
|
|
dropUnknownForceAnalysisText: {
|
|
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?',
|
|
},
|
|
dropUnknownForceAnalysisButton: {
|
|
id: 'app.drop.unknown-force-analysis-button',
|
|
defaultMessage: 'Force analysis',
|
|
},
|
|
dropUnknownForceAnalyzing: {
|
|
id: 'app.drop.unknown-force-analyzing',
|
|
defaultMessage: 'Force analyzing archive...',
|
|
},
|
|
dropUnknownForceAnalysisFailedTitle: {
|
|
id: 'app.drop.unknown-force-analysis-failed-title',
|
|
defaultMessage: 'Analysis failed',
|
|
},
|
|
dropUnknownForceAnalysisFailedText: {
|
|
id: 'app.drop.unknown-force-analysis-failed-text',
|
|
defaultMessage: 'Could not identify the file type even after deep analysis.',
|
|
},
|
|
dropInstallModTitle: {
|
|
id: 'app.drop.mod-compatibility-title',
|
|
defaultMessage: 'Version Mismatch',
|
|
},
|
|
dropInstallModWarning: {
|
|
id: 'app.drop.mod-compatibility-warning',
|
|
defaultMessage:
|
|
'{file} targets {modVersion} ({modLoader}), but the instance is {instVersion} ({instLoader}).',
|
|
},
|
|
dropCompatibleModeTitle: {
|
|
id: 'app.drop.compatible-mode-title',
|
|
defaultMessage: 'This appears to be a pre-version-isolation instance',
|
|
},
|
|
dropCompatibleModeDesc: {
|
|
id: 'app.drop.compatible-mode-desc',
|
|
defaultMessage: 'Would you like to import this as a compatible mode instance?',
|
|
},
|
|
dropCompatibleModeImport: {
|
|
id: 'app.drop.compatible-mode-import',
|
|
defaultMessage: 'Compatible Import',
|
|
},
|
|
dropCompatibleModeOldWay: {
|
|
id: 'app.drop.compatible-mode-old-way',
|
|
defaultMessage: 'Import as Old Version',
|
|
},
|
|
dropCompatibleModeCancel: {
|
|
id: 'app.drop.compatible-mode-cancel',
|
|
defaultMessage: 'Cancel',
|
|
},
|
|
})
|
|
|
|
// ── Helper functions ─────────────────────────────────────────────────
|
|
|
|
function clearDropProcessingNotification() {
|
|
if (dropProcessingNotificationId.value !== null) {
|
|
notificationManager.removeNotification(dropProcessingNotificationId.value)
|
|
dropProcessingNotificationId.value = null
|
|
}
|
|
}
|
|
|
|
function isZipPath(path: string): boolean {
|
|
return /\.zip$/i.test(path)
|
|
}
|
|
|
|
function unknownReasonMessage(reason: string | undefined): string {
|
|
const normalized = reason?.toLowerCase() ?? ''
|
|
if (normalized.includes('too deep') || normalized.includes('nesting')) {
|
|
return formatMessage(messages.dropUnknownDepthText)
|
|
}
|
|
if (normalized.includes('encrypted')) {
|
|
return formatMessage(messages.dropUnknownEncryptedText)
|
|
}
|
|
return reason ? reason : formatMessage(messages.dropUnknownText)
|
|
}
|
|
|
|
async function cleanupLauncherZipTemp() {
|
|
const tempDir = launcherZipTempDir.value
|
|
if (!tempDir) return
|
|
launcherZipTempDir.value = null
|
|
try {
|
|
await removeTempDir(tempDir)
|
|
dropDebug('handleDropConfirm: launcher zip temp cleaned', { tempDir })
|
|
} catch (error) {
|
|
dropDebug('handleDropConfirm: launcher zip temp cleanup failed', error)
|
|
}
|
|
}
|
|
|
|
// ── Classification ───────────────────────────────────────────────────
|
|
|
|
async function classifyDropPath(path: string): Promise<ClassificationResult> {
|
|
lastDroppedPath.value = path
|
|
if (onSkinsPage.value) {
|
|
return { item_type: 'unknown' as const, file_path: path, reason: 'skipped' }
|
|
}
|
|
if (onSchematicWorkshopPage.value && isSchematicFile(path)) {
|
|
return { item_type: 'unknown' as const, file_path: path, reason: 'skipped' }
|
|
}
|
|
return classifyDroppedItem(path)
|
|
}
|
|
|
|
function resolveBatchClassification(
|
|
result: ClassificationResult,
|
|
depth = 0,
|
|
): ClassificationResult {
|
|
if (result.item_type === 'shortcut_resolved' && result.resolved_to && depth < 3) {
|
|
return resolveBatchClassification(result.resolved_to, depth + 1)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ── Compatible mode ──────────────────────────────────────────────────
|
|
|
|
async function handleCompatibleModeConfirm(choice: 'compatible' | 'old-way' | 'cancel') {
|
|
if (choice === 'cancel') {
|
|
currentImportContext.value = null
|
|
return
|
|
}
|
|
|
|
const gameDir = compatibleModeGameDir.value ?? dropFilePath.value!
|
|
const launcherType = compatibleModeLauncherType.value
|
|
const scanResults = compatibleModeResults.value
|
|
const instanceName = scanResults?.[0]?.instances[0]?.name ?? ''
|
|
// Strip launcher prefix and "versions/" prefix if present
|
|
// (e.g. ".minecraft:versions/1.12.2" → "1.12.2")
|
|
let versionName = instanceName
|
|
const colonIdx = versionName.lastIndexOf(':')
|
|
if (colonIdx >= 0) {
|
|
versionName = versionName.slice(colonIdx + 1)
|
|
}
|
|
if (versionName.startsWith('versions/')) {
|
|
versionName = versionName.slice('versions/'.length)
|
|
}
|
|
dropDebug('handleCompatibleModeConfirm', {
|
|
choice,
|
|
gameDir,
|
|
instanceName,
|
|
versionName,
|
|
launcherType,
|
|
})
|
|
|
|
if (choice === 'compatible') {
|
|
const single = scanResults?.[0]?.instances[0]
|
|
if (!single?.versionPath) {
|
|
currentImportContext.value = null
|
|
dropDebug('handleCompatibleModeConfirm: missing versionPath', {
|
|
instanceName,
|
|
})
|
|
addNotification({
|
|
title: formatMessage(messages.dropNoInstances),
|
|
type: 'error',
|
|
})
|
|
return
|
|
}
|
|
selectedInstances.value = [
|
|
{
|
|
launcherType,
|
|
basePath: gameDir,
|
|
name: versionName,
|
|
path: gameDir,
|
|
compatibleMode: true,
|
|
versionPath: single.versionPath,
|
|
},
|
|
]
|
|
const cap = await check_symlink_capability()
|
|
symlinkCardsModal.value?.show({
|
|
instances: [
|
|
{
|
|
name: versionName,
|
|
path: gameDir,
|
|
launcherType,
|
|
basePath: gameDir,
|
|
compatibleMode: true,
|
|
versionPath: single.versionPath,
|
|
},
|
|
],
|
|
symlinkCapable: cap,
|
|
})
|
|
} else if (choice === 'old-way') {
|
|
const single = scanResults![0].instances[0]
|
|
selectedInstances.value = [
|
|
{
|
|
launcherType,
|
|
basePath: gameDir,
|
|
name: versionName,
|
|
path: single.path,
|
|
},
|
|
]
|
|
const cap = await check_symlink_capability()
|
|
symlinkCardsModal.value?.show({
|
|
instances: [
|
|
{
|
|
name: versionName,
|
|
path: single.path,
|
|
launcherType,
|
|
basePath: gameDir,
|
|
},
|
|
],
|
|
symlinkCapable: cap,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Single-file drop flow ────────────────────────────────────────────
|
|
|
|
function handleDropCancel() {
|
|
clearDropProcessingNotification()
|
|
dropClassification.value = null
|
|
}
|
|
|
|
function handleConfirmDropCancel() {
|
|
if (batchGroupMode) {
|
|
batchGroupMode = false
|
|
confirmDropModal.value?.hide()
|
|
void cancelBatch('group-cancel')
|
|
return
|
|
}
|
|
handleDropCancel()
|
|
}
|
|
|
|
async function handleConfirmDropConfirm(type: string, innerBase?: string) {
|
|
if (batchGroupMode) {
|
|
batchGroupMode = false
|
|
confirmDropModal.value?.hide()
|
|
onBatchGroupConfirm(type)
|
|
return
|
|
}
|
|
await handleDropConfirm(type, innerBase)
|
|
}
|
|
|
|
async function handleConfirmDropHelp() {
|
|
if (batchGroupMode) {
|
|
batchGroupMode = false
|
|
confirmDropModal.value?.hide()
|
|
void cancelBatch('group-help')
|
|
}
|
|
await handleDropHelp()
|
|
}
|
|
|
|
async function handleDropConfirm(type: string, innerBase?: string) {
|
|
const classification = dropClassification.value
|
|
dropClassification.value = null
|
|
confirmDropModal.value?.hide()
|
|
|
|
dropDebug('handleDropConfirm: entry', {
|
|
type,
|
|
classification_item_type: classification?.item_type,
|
|
file_path: classification?.file_path,
|
|
})
|
|
|
|
const isLauncherImport =
|
|
classification?.item_type === 'launcher' || classification?.item_type === 'hmcl_launcher'
|
|
|
|
if (!isLauncherImport && !classification?.file_path && !dropFilePath.value) {
|
|
dropDebug(
|
|
'handleDropConfirm: no filePath available (classification and dropFilePath both empty), aborting',
|
|
)
|
|
return
|
|
}
|
|
|
|
const filePath = classification?.file_path ?? dropFilePath.value
|
|
const fileName =
|
|
filePath?.split(/[/\\]/).pop() ?? classification?.base_path?.split(/[/\\]/).pop() ?? 'file'
|
|
dropDebug('handleDropConfirm: routing decision', {
|
|
type,
|
|
isLauncherImport,
|
|
item_type: classification?.item_type,
|
|
})
|
|
|
|
if (type === 'dot_minecraft') {
|
|
dropDebug('handleDropConfirm:.minecraft folder branch', {
|
|
dropFilePath: dropFilePath.value,
|
|
})
|
|
if (!dropFilePath.value) {
|
|
dropDebug('handleDropConfirm: dot_minecraft — no dropFilePath, aborting')
|
|
return
|
|
}
|
|
currentImportContext.value = { launcherType: 'Generic', basePath: dropFilePath.value }
|
|
scanningInstances.value = true
|
|
let results: ScanResult[]
|
|
try {
|
|
results = await scanLauncherInstances('Generic', dropFilePath.value)
|
|
} catch (error) {
|
|
currentImportContext.value = null
|
|
dropDebug('handleDropConfirm:.minecraft scan failed', error)
|
|
addNotification({ title: formatMessage(messages.dropScanFailed), type: 'error' })
|
|
return
|
|
} finally {
|
|
scanningInstances.value = false
|
|
}
|
|
const totalInstances = results.reduce((s, r) => s + r.instances.length, 0)
|
|
dropDebug('handleDropConfirm:.minecraft scan result', { totalInstances, results })
|
|
|
|
if (totalInstances === 0) {
|
|
currentImportContext.value = null
|
|
dropDebug('handleDropConfirm: no instances found in.minecraft folder')
|
|
addNotification({ title: formatMessage(messages.dropNoInstances), type: 'warning' })
|
|
return
|
|
}
|
|
|
|
if (totalInstances === 1 && results[0]?.instances[0]) {
|
|
const single = results[0].instances[0]
|
|
dropDebug('handleDropConfirm: single instance from.minecraft, showing symlink modal', {
|
|
name: single.name,
|
|
path: single.path,
|
|
})
|
|
selectedInstances.value = [
|
|
{
|
|
launcherType: 'Generic',
|
|
basePath: single.compatibleMode ? single.path : dropFilePath.value,
|
|
name: single.name,
|
|
path: single.compatibleMode ? (single.versionPath ?? single.path) : single.path,
|
|
compatibleMode: single.compatibleMode,
|
|
versionPath: single.versionPath,
|
|
},
|
|
]
|
|
const cap = await check_symlink_capability()
|
|
symlinkCardsModal.value?.show({
|
|
instances: [
|
|
{
|
|
name: single.name,
|
|
path: single.compatibleMode ? (single.versionPath ?? single.path) : single.path,
|
|
launcherType: 'Generic',
|
|
basePath: single.compatibleMode ? single.path : dropFilePath.value,
|
|
compatibleMode: single.compatibleMode,
|
|
versionPath: single.versionPath,
|
|
},
|
|
],
|
|
symlinkCapable: cap,
|
|
})
|
|
return
|
|
}
|
|
|
|
dropDebug(
|
|
'handleDropConfirm: multiple instances from.minecraft, showing launcher import modal',
|
|
)
|
|
launcherImportModal.value?.show(results)
|
|
return
|
|
}
|
|
|
|
if (isLauncherImport && type === 'instance') {
|
|
const launcherType =
|
|
classification!.item_type === 'hmcl_launcher' ? 'HMCL' : classification!.launcher_type!
|
|
const basePath =
|
|
classification!.item_type === 'hmcl_launcher'
|
|
? classification!.launcher_dir!
|
|
: classification!.base_path!
|
|
dropDebug('handleDropConfirm: launcher import branch', { launcherType, basePath })
|
|
|
|
let scanBasePath = basePath
|
|
if (isZipPath(basePath)) {
|
|
scanningInstances.value = true
|
|
try {
|
|
const tempDir = await extractZipToTemp(basePath)
|
|
launcherZipTempDir.value = tempDir
|
|
scanBasePath = classification!.innerBase
|
|
? await join(tempDir, classification!.innerBase)
|
|
: tempDir
|
|
dropDebug('handleDropConfirm: extracted launcher zip', {
|
|
tempDir,
|
|
innerBase: classification!.innerBase,
|
|
scanBasePath,
|
|
})
|
|
} catch (error) {
|
|
launcherZipTempDir.value = null
|
|
const errorDetail = error instanceof Error ? error.message : String(error)
|
|
console.error('[DropFlow] launcher zip extraction failed:', errorDetail, basePath)
|
|
dropDebug('handleDropConfirm: launcher zip extraction failed', error)
|
|
addNotification({
|
|
title: formatMessage(messages.dropExtractFailed),
|
|
text: errorDetail,
|
|
type: 'error',
|
|
})
|
|
return
|
|
} finally {
|
|
scanningInstances.value = false
|
|
}
|
|
}
|
|
|
|
currentImportContext.value = { launcherType, basePath: scanBasePath }
|
|
scanningInstances.value = true
|
|
let results: ScanResult[]
|
|
try {
|
|
results = await scanLauncherInstances(launcherType, scanBasePath)
|
|
} catch (error) {
|
|
currentImportContext.value = null
|
|
dropDebug('handleDropConfirm: launcher scan failed', error)
|
|
addNotification({ title: formatMessage(messages.dropScanFailed), type: 'error' })
|
|
cleanupLauncherZipTemp()
|
|
return
|
|
} finally {
|
|
scanningInstances.value = false
|
|
}
|
|
const totalInstances = results.reduce((s, r) => s + r.instances.length, 0)
|
|
dropDebug('handleDropConfirm: launcher scan result', { totalInstances, results })
|
|
|
|
if (totalInstances === 0) {
|
|
currentImportContext.value = null
|
|
dropDebug('handleDropConfirm: no instances found')
|
|
addNotification({ title: formatMessage(messages.dropNoInstances), type: 'warning' })
|
|
cleanupLauncherZipTemp()
|
|
return
|
|
}
|
|
|
|
if (totalInstances === 1 && results[0]?.instances[0]) {
|
|
const single = results[0].instances[0]
|
|
dropDebug('handleDropConfirm: single instance, showing symlink modal', {
|
|
name: single.name,
|
|
path: single.path,
|
|
})
|
|
selectedInstances.value = [
|
|
{
|
|
launcherType,
|
|
basePath: single.compatibleMode ? single.path : scanBasePath,
|
|
name: single.name,
|
|
path: single.compatibleMode ? (single.versionPath ?? single.path) : single.path,
|
|
compatibleMode: single.compatibleMode,
|
|
versionPath: single.versionPath,
|
|
},
|
|
]
|
|
if (launcherZipTempDir.value) {
|
|
dropDebug('handleDropConfirm: zip source, importing as copy')
|
|
await onSymlinkMethodConfirmed(false)
|
|
return
|
|
}
|
|
const cap = await check_symlink_capability()
|
|
symlinkCardsModal.value?.show({
|
|
instances: [
|
|
{
|
|
name: single.name,
|
|
path: single.compatibleMode ? (single.versionPath ?? single.path) : single.path,
|
|
launcherType,
|
|
basePath: single.compatibleMode ? single.path : scanBasePath,
|
|
compatibleMode: single.compatibleMode,
|
|
versionPath: single.versionPath,
|
|
},
|
|
],
|
|
symlinkCapable: cap,
|
|
})
|
|
return
|
|
}
|
|
|
|
dropDebug('handleDropConfirm: multiple instances, showing launcher import modal')
|
|
launcherImportModal.value?.show(results)
|
|
return
|
|
}
|
|
|
|
if (type === 'modpack') {
|
|
dropDebug('handleDropConfirm: modpack branch', { filePath, fileName })
|
|
|
|
if (!filePath) {
|
|
dropDebug('handleDropConfirm: modpack — no filePath, aborting')
|
|
addNotification({ title: formatMessage(messages.dropModpackInstallFailed), type: 'error' })
|
|
return
|
|
}
|
|
|
|
clearDropProcessingNotification()
|
|
await installModpackFromPath(filePath, fileName, { persistUntilDone: true })
|
|
await router.push('/library')
|
|
return
|
|
}
|
|
|
|
const contentTypes = [
|
|
'mod',
|
|
'resource_pack',
|
|
'data_pack',
|
|
'shader_pack',
|
|
'world_save',
|
|
'litematic',
|
|
'schematic',
|
|
]
|
|
if (!contentTypes.includes(type)) {
|
|
dropDebug('handleDropConfirm: type not in contentTypes — FALLTHROUGH, no handler!', {
|
|
type,
|
|
contentTypes,
|
|
})
|
|
return
|
|
}
|
|
|
|
dropDebug('handleDropConfirm: content install branch', {
|
|
type,
|
|
isInInstance: isInInstance.value,
|
|
hasInstanceId: !!instanceId.value,
|
|
})
|
|
|
|
if (type === 'data_pack') {
|
|
dropDebug('handleDropConfirm: data pack requires a world target', {
|
|
filePath,
|
|
})
|
|
pendingInstall.value = { type, filePath, innerBase }
|
|
dataPackWorldModal.value?.show(
|
|
isInInstance.value ? (instanceId.value ?? undefined) : undefined,
|
|
)
|
|
return
|
|
}
|
|
|
|
if (isInInstance.value && instanceId.value) {
|
|
dropDebug('handleDropConfirm: installing directly to current instance', {
|
|
instanceId: instanceId.value,
|
|
})
|
|
await installContentDirectly(type, filePath, instanceId.value, innerBase)
|
|
} else {
|
|
dropDebug('handleDropConfirm: storing pending install, showing instance selection modal')
|
|
pendingInstall.value = { type, filePath, innerBase }
|
|
|
|
let instances: BatchTargetInstanceInfo[] = []
|
|
try {
|
|
const allInstances = await listInstances()
|
|
instances = allInstances.map((inst) => ({
|
|
id: inst.id,
|
|
name: inst.name,
|
|
iconUrl: getDisplayInstanceIcon(inst.icon_path, inst.loader).url,
|
|
gameVersion: inst.game_version || null,
|
|
loader: inst.loader || null,
|
|
}))
|
|
} catch {
|
|
// If listing fails, show empty list
|
|
}
|
|
genericInstallModal.value?.show({
|
|
contentType: type,
|
|
fileName,
|
|
instances,
|
|
})
|
|
}
|
|
}
|
|
|
|
async function installContentDirectly(
|
|
type: string,
|
|
filePath: string,
|
|
instId: string,
|
|
innerBase?: string,
|
|
) {
|
|
try {
|
|
if (type === 'world_save') {
|
|
await import_world_save(instId, filePath, innerBase)
|
|
addNotification({
|
|
title: formatMessage(messages.dropWorldImportedTitle),
|
|
text: formatMessage(messages.dropWorldImportedText),
|
|
type: 'success',
|
|
})
|
|
return
|
|
}
|
|
|
|
if (type === 'mod') {
|
|
let meta: {
|
|
minecraft_version?: string
|
|
loader?: string
|
|
name?: string
|
|
mod_id?: string
|
|
} | null = null
|
|
let modrinthLookup: ModrinthLookupResult | null = null
|
|
|
|
const metaStr = await extractModMetadata(filePath)
|
|
dropDebug('installContentDirectly: mod metadata extraction', {
|
|
filePath,
|
|
hasMeta: !!metaStr,
|
|
})
|
|
|
|
if (metaStr) {
|
|
try {
|
|
meta = JSON.parse(metaStr)
|
|
dropDebug('installContentDirectly: parsed mod metadata', { meta })
|
|
} catch (e) {
|
|
dropDebug('installContentDirectly: failed to parse mod metadata', { error: e })
|
|
}
|
|
}
|
|
|
|
try {
|
|
modrinthLookup = await lookupModHash(filePath)
|
|
dropDebug('installContentDirectly: modrinth hash lookup', {
|
|
found: !!modrinthLookup,
|
|
})
|
|
} catch (e) {
|
|
dropDebug('installContentDirectly: hash lookup failed', { error: e })
|
|
}
|
|
|
|
const inst = await getInstance(instId)
|
|
dropDebug('installContentDirectly: instance details', {
|
|
inst: inst?.id,
|
|
game_version: inst?.game_version,
|
|
loader: inst?.loader,
|
|
})
|
|
|
|
if (inst && meta?.minecraft_version) {
|
|
const instVersion = inst.game_version
|
|
const instLoader = inst.loader
|
|
const modMcVersion = meta.minecraft_version
|
|
const modLoader = meta.loader
|
|
|
|
let versionMismatch = false
|
|
if (modMcVersion && instVersion) {
|
|
versionMismatch = !isVersionInRange(instVersion, modMcVersion)
|
|
}
|
|
|
|
let loaderMismatch = false
|
|
if (modLoader && instLoader) {
|
|
loaderMismatch = !areLoadersCompatible(modLoader, instLoader)
|
|
}
|
|
|
|
dropDebug('installContentDirectly: compatibility check', {
|
|
versionMismatch,
|
|
loaderMismatch,
|
|
modMcVersion,
|
|
instVersion,
|
|
modLoader,
|
|
instLoader,
|
|
})
|
|
|
|
if (versionMismatch || loaderMismatch) {
|
|
pendingDropIncompatibility.value = {
|
|
filePath,
|
|
instId,
|
|
type,
|
|
instVersion,
|
|
instLoader,
|
|
meta,
|
|
modrinthLookup,
|
|
}
|
|
const warning = formatMessage(messages.dropInstallModWarning, {
|
|
file: filePath.split(/[/\\]/).pop() || filePath,
|
|
modVersion: modMcVersion ?? 'any',
|
|
modLoader: modLoader ?? 'any',
|
|
instVersion: instVersion ?? 'any',
|
|
instLoader: instLoader ?? 'none',
|
|
})
|
|
contentInstall.incompatibilityWarningVersions.value = []
|
|
contentInstall.incompatibilityWarningCurrentGameVersion.value = instVersion ?? ''
|
|
contentInstall.incompatibilityWarningCurrentLoader.value = instLoader ?? ''
|
|
contentInstall.incompatibilityWarningProjectType.value = 'mod'
|
|
contentInstall.incompatibilityWarningProjectName.value = meta?.name ?? 'Mod'
|
|
contentInstall.incompatibilityWarningMessage.value = warning
|
|
contentInstall.incompatibilityWarningInstalling.value = false
|
|
incompatWarningKey.value++
|
|
await nextTick()
|
|
incompatibilityWarningModal.value?.show()
|
|
return
|
|
}
|
|
} else {
|
|
dropDebug('installContentDirectly: skipping version check', {
|
|
hasInstance: !!inst,
|
|
hasModVersion: !!meta?.minecraft_version,
|
|
})
|
|
}
|
|
}
|
|
|
|
const projectType = contentFileProjectTypeMap[type]
|
|
await add_project_from_path(instId, filePath, projectType, innerBase)
|
|
addNotification({
|
|
title: formatMessage(messages.dropContentInstalledTitle),
|
|
text: formatMessage(messages.dropContentInstalledText),
|
|
type: 'success',
|
|
})
|
|
} catch (e) {
|
|
let errMsg = e instanceof Error ? e.message : typeof e === 'string' ? e : JSON.stringify(e)
|
|
try {
|
|
const lockInfo = await detectFileLock(filePath)
|
|
if (lockInfo.length > 0) {
|
|
const lockLines = lockInfo.map((p) => ` PID ${p.pid}: ${p.name} (${p.path})`).join('\n')
|
|
errMsg += `\n\nFile locked by:\n${lockLines}`
|
|
}
|
|
} catch {
|
|
// Lock detection is best-effort
|
|
}
|
|
addNotification({
|
|
title: formatMessage(messages.dropInstallFailedTitle),
|
|
text: errMsg,
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
async function handleDropHelp() {
|
|
await router.push('/help/drop')
|
|
await confirmDropModal.value?.hide()
|
|
}
|
|
|
|
// ── Force analysis & nested unpack prompts ───────────────────────────
|
|
|
|
function showForceAnalysisPrompt(classification: ClassificationResult) {
|
|
const filePath = dropFilePath.value
|
|
if (!filePath) return
|
|
|
|
dropDebug('showForceAnalysisPrompt: showing force-analysis prompt', {
|
|
reason: classification.reason,
|
|
filePath,
|
|
})
|
|
|
|
addPopupNotification({
|
|
title: formatMessage(messages.dropUnknownForceAnalysisTitle),
|
|
text: formatMessage(messages.dropUnknownForceAnalysisText),
|
|
type: 'info',
|
|
autoCloseMs: null,
|
|
buttons: [
|
|
{
|
|
label: formatMessage(messages.dropUnknownForceAnalysisButton),
|
|
action: async () => {
|
|
const analyzingNotification = addNotification({
|
|
title: formatMessage(messages.dropUnknownForceAnalyzing),
|
|
type: 'info',
|
|
autoCloseMs: null,
|
|
})
|
|
|
|
try {
|
|
const result = await classifyDroppedItemWithExtraction(filePath)
|
|
notificationManager.removeNotification(analyzingNotification.id)
|
|
|
|
if (result.item_type === 'unknown') {
|
|
addNotification({
|
|
title: formatMessage(messages.dropUnknownForceAnalysisFailedTitle),
|
|
text: formatMessage(messages.dropUnknownForceAnalysisFailedText),
|
|
type: 'error',
|
|
})
|
|
return
|
|
}
|
|
|
|
await continueWithClassification(result, filePath)
|
|
} catch (e) {
|
|
notificationManager.removeNotification(analyzingNotification.id)
|
|
addNotification({
|
|
title: formatMessage(messages.dropUnknownForceAnalysisFailedTitle),
|
|
text: e instanceof Error ? e.message : String(e),
|
|
type: 'error',
|
|
})
|
|
}
|
|
},
|
|
color: 'brand',
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
async function continueWithClassification(
|
|
result: ClassificationResult,
|
|
fallbackFileName: string,
|
|
) {
|
|
if (result.item_type === 'unknown') {
|
|
addNotification({
|
|
title: formatMessage(messages.dropUnknownTitle),
|
|
text: unknownReasonMessage(result.reason),
|
|
type: 'error',
|
|
})
|
|
return
|
|
}
|
|
dropClassification.value = result
|
|
dropFilePath.value = result.file_path ?? result.base_path ?? ''
|
|
dropFileName.value =
|
|
result.file_path?.split(/[/\\]/).pop() ??
|
|
result.base_path?.split(/[/\\]/).pop() ??
|
|
fallbackFileName
|
|
|
|
switch (result.item_type) {
|
|
case 'modpack':
|
|
await handleDropConfirm('modpack')
|
|
break
|
|
case 'world_save':
|
|
await handleDropConfirm('world_save')
|
|
break
|
|
case 'launcher':
|
|
case 'hmcl_launcher':
|
|
await handleDropConfirm('instance')
|
|
break
|
|
default:
|
|
if (result.item_type === 'resource_pack' || result.item_type === 'multiple') {
|
|
confirmDropModal.value?.show()
|
|
return
|
|
}
|
|
await handleDropConfirm(result.item_type)
|
|
break
|
|
}
|
|
}
|
|
|
|
function showNestedUnpackPrompt(classification: ClassificationResult) {
|
|
const filePath = dropFilePath.value
|
|
if (!filePath) return
|
|
|
|
dropDebug('showNestedUnpackPrompt: nested archives need unpacking', {
|
|
reason: classification.reason,
|
|
filePath,
|
|
})
|
|
|
|
const sizeBytes = Number(classification.reason?.match(/total (\d+) bytes/i)?.[1] ?? 0)
|
|
const sizeLabel =
|
|
sizeBytes > 0
|
|
? sizeBytes >= 1024 * 1024
|
|
? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
|
|
: `${Math.max(1, Math.round(sizeBytes / 1024))} KB`
|
|
: '?'
|
|
|
|
addPopupNotification({
|
|
title: formatMessage(messages.dropNestedUnpackTitle),
|
|
text: formatMessage(messages.dropNestedUnpackText, { size: sizeLabel }),
|
|
type: 'info',
|
|
autoCloseMs: null,
|
|
buttons: [
|
|
{
|
|
label: formatMessage(messages.dropNestedUnpackButton),
|
|
action: async () => {
|
|
try {
|
|
const result = await classifyDroppedItem(filePath, true)
|
|
await continueWithClassification(result, dropFileName.value || 'file')
|
|
} catch (e) {
|
|
addNotification({
|
|
title: formatMessage(messages.dropProcessFailedTitle),
|
|
text: e instanceof Error ? e.message : String(e),
|
|
type: 'error',
|
|
})
|
|
}
|
|
},
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
// ── Generic install handlers ─────────────────────────────────────────
|
|
|
|
async function handleGenericInstall(targetInstanceId: string) {
|
|
if (batchTargetPickMode) {
|
|
batchTargetPickMode = false
|
|
genericInstallModal.value?.hide()
|
|
try {
|
|
await proceedBatchWithTargetInstance(targetInstanceId)
|
|
} catch (error) {
|
|
await failBatch(error)
|
|
}
|
|
return
|
|
}
|
|
genericInstallModal.value?.hide()
|
|
const pending = pendingInstall.value
|
|
pendingInstall.value = null
|
|
if (!pending) return
|
|
|
|
await installContentDirectly(
|
|
pending.type,
|
|
pending.filePath,
|
|
targetInstanceId,
|
|
pending.innerBase,
|
|
)
|
|
}
|
|
|
|
function handleGenericInstallCancel() {
|
|
if (batchTargetPickMode) {
|
|
batchTargetPickMode = false
|
|
genericInstallModal.value?.hide()
|
|
void cancelBatch('target-instance-cancel')
|
|
return
|
|
}
|
|
dropClassification.value = null
|
|
}
|
|
|
|
async function handleGenericInstallNavigateCreate() {
|
|
if (batchTargetPickMode) {
|
|
batchTargetPickMode = false
|
|
genericInstallModal.value?.hide()
|
|
void cancelBatch('target-instance-navigate-create')
|
|
}
|
|
router.push('/create')
|
|
}
|
|
|
|
async function handleDatapackWorldSelect(target: { instanceId: string; worldPath: string }) {
|
|
const pending = pendingInstall.value
|
|
pendingInstall.value = null
|
|
if (!pending) return
|
|
clearDropProcessingNotification()
|
|
try {
|
|
await install_datapack_to_world(
|
|
target.instanceId,
|
|
target.worldPath,
|
|
pending.filePath,
|
|
pending.innerBase,
|
|
)
|
|
addNotification({
|
|
title: formatMessage(messages.dropContentInstalledTitle),
|
|
text: formatMessage(messages.dropContentInstalledText),
|
|
type: 'success',
|
|
})
|
|
} catch (e) {
|
|
addNotification({
|
|
title: formatMessage(messages.dropInstallFailedTitle),
|
|
text: e instanceof Error ? e.message : typeof e === 'string' ? e : JSON.stringify(e),
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Launcher import handlers ─────────────────────────────────────────
|
|
|
|
function onLauncherImportCancelled() {
|
|
launcherImportModal.value?.hide()
|
|
cleanupLauncherZipTemp()
|
|
}
|
|
|
|
function chooseImportMethod(options: {
|
|
instanceNames: string[]
|
|
symlinkCapable: 'supported' | 'requires_admin' | 'unsupported'
|
|
}): Promise<SymlinkMethodChoice[]> {
|
|
return new Promise((resolve) => {
|
|
symlinkChoiceResolve = resolve
|
|
symlinkCardsModal.value?.show({
|
|
instances: options.instanceNames.map((name) => ({ name })),
|
|
symlinkCapable: options.symlinkCapable,
|
|
})
|
|
})
|
|
}
|
|
|
|
async function onImportSelected(
|
|
selections: Array<{
|
|
launcherType: string
|
|
launcherName: string
|
|
instances: Array<{
|
|
name: string
|
|
path: string
|
|
compatibleMode?: boolean
|
|
versionPath?: string
|
|
}>
|
|
}>,
|
|
) {
|
|
const allSelected: SelectedInstance[] = []
|
|
for (const sel of selections) {
|
|
for (const inst of sel.instances) {
|
|
allSelected.push({
|
|
launcherType: sel.launcherType,
|
|
basePath: inst.compatibleMode ? inst.path : '',
|
|
name: inst.name,
|
|
path: inst.compatibleMode ? (inst.versionPath ?? inst.path) : inst.path,
|
|
compatibleMode: inst.compatibleMode,
|
|
versionPath: inst.versionPath,
|
|
})
|
|
}
|
|
}
|
|
if (allSelected.length === 0) return
|
|
selectedInstances.value = allSelected
|
|
|
|
if (launcherZipTempDir.value) {
|
|
dropDebug('onImportSelected: zip source, importing as copy', {
|
|
count: allSelected.length,
|
|
})
|
|
await onSymlinkMethodConfirmed(false)
|
|
return
|
|
}
|
|
|
|
const cap = await check_symlink_capability()
|
|
symlinkCardsModal.value?.show({
|
|
instances: allSelected.map((i) => ({
|
|
name: i.name,
|
|
path: i.path,
|
|
launcherType: i.launcherType,
|
|
basePath: i.basePath || i.path,
|
|
compatibleMode: i.compatibleMode,
|
|
versionPath: i.versionPath,
|
|
})),
|
|
symlinkCapable: cap,
|
|
})
|
|
}
|
|
|
|
function onSymlinkMethodCancelled() {
|
|
if (symlinkChoiceResolve) {
|
|
symlinkChoiceResolve([])
|
|
symlinkChoiceResolve = null
|
|
}
|
|
symlinkCardsModal.value?.hide()
|
|
if (batchSymlinkMode) {
|
|
batchSymlinkMode = false
|
|
void cancelBatch('symlink-cancel')
|
|
return
|
|
}
|
|
cleanupLauncherZipTemp()
|
|
}
|
|
|
|
function resolvedInstancePath(
|
|
inst: SelectedInstance,
|
|
ctx: ImportContext | null,
|
|
): string | undefined {
|
|
if (inst.compatibleMode) return inst.versionPath
|
|
return inst.path
|
|
}
|
|
|
|
async function onSymlinkMethodConfirmed(choices: SymlinkMethodChoice[] | boolean) {
|
|
if (symlinkChoiceResolve) {
|
|
symlinkChoiceResolve(Array.isArray(choices) ? choices : [])
|
|
symlinkChoiceResolve = null
|
|
return
|
|
}
|
|
|
|
if (batchSymlinkMode) {
|
|
const group = batchCurrentGroup.value
|
|
batchSymlinkMode = false
|
|
symlinkCardsModal.value?.hide()
|
|
if (group) {
|
|
const choiceArray = Array.isArray(choices) ? choices : []
|
|
for (const item of group.items) {
|
|
const choice = choiceArray.find(
|
|
(c) =>
|
|
c.instanceName === item.name &&
|
|
(c.instancePath ?? undefined) === (item.instancePath ?? undefined),
|
|
)
|
|
if (choice) {
|
|
item.symlink = choice.symlink
|
|
item.gameVersion = choice.gameVersion
|
|
item.loader = choice.loader
|
|
item.loaderVersion = choice.loaderVersion
|
|
item.gameDirOverride = choice.gameDirOverride
|
|
}
|
|
}
|
|
}
|
|
batchConfirmIndex++
|
|
void showNextBatchGroup()
|
|
return
|
|
}
|
|
|
|
const instances = selectedInstances.value
|
|
selectedInstances.value = []
|
|
const ctx = currentImportContext.value
|
|
currentImportContext.value = null
|
|
if (instances.length === 0) {
|
|
cleanupLauncherZipTemp()
|
|
return
|
|
}
|
|
|
|
if (instances.length === 1) {
|
|
const inst = instances[0]
|
|
const choice = Array.isArray(choices)
|
|
? choices.find(
|
|
(c) =>
|
|
c.instanceName === inst.name &&
|
|
(c.instancePath ?? undefined) === (inst.path ?? undefined),
|
|
)
|
|
: undefined
|
|
try {
|
|
const job = await import_instance(
|
|
ctx?.launcherType ?? inst.launcherType,
|
|
inst.compatibleMode ? inst.basePath : (ctx?.basePath ?? inst.path),
|
|
inst.name,
|
|
choice?.symlink ?? (Array.isArray(choices) ? false : choices),
|
|
resolvedInstancePath(inst),
|
|
inst.compatibleMode ? undefined : (choice?.gameVersion ?? undefined),
|
|
inst.compatibleMode ? undefined : (choice?.loader ?? undefined),
|
|
inst.compatibleMode ? undefined : (choice?.loaderVersion ?? undefined),
|
|
choice?.gameDirOverride ?? null,
|
|
)
|
|
await wait_for_install_job(job.job_id)
|
|
addNotification({
|
|
title: formatMessage(messages.dropInstanceImportedTitle),
|
|
text: formatMessage(messages.dropInstanceImportedText, { name: inst.name }),
|
|
type: 'success',
|
|
})
|
|
} catch (e) {
|
|
addNotification({
|
|
title: formatMessage(messages.dropImportFailedTitle),
|
|
text: formatMessage(messages.dropImportFailedText, {
|
|
name: inst.name,
|
|
error: String(e),
|
|
}),
|
|
type: 'error',
|
|
})
|
|
} finally {
|
|
cleanupLauncherZipTemp()
|
|
}
|
|
return
|
|
}
|
|
|
|
const total = instances.length
|
|
let completed = 0
|
|
let failedCount = 0
|
|
|
|
let progressNotif = addNotification({
|
|
title: formatMessage(messages.dropImportProgressTitle),
|
|
text: formatMessage(messages.dropImportProgressText, { current: 0, total }),
|
|
type: 'info',
|
|
autoCloseMs: null,
|
|
})
|
|
|
|
for (let i = 0; i < instances.length; i++) {
|
|
const inst = instances[i]
|
|
const choice = Array.isArray(choices)
|
|
? choices.find(
|
|
(c) =>
|
|
c.instanceName === inst.name &&
|
|
(c.instancePath ?? undefined) === (inst.path ?? undefined),
|
|
)
|
|
: undefined
|
|
|
|
notificationManager.removeNotification(progressNotif.id)
|
|
progressNotif = addNotification({
|
|
title: formatMessage(messages.dropImportProgressTitle),
|
|
text: formatMessage(messages.dropImportProgressText, {
|
|
current: i + 1,
|
|
total,
|
|
}),
|
|
type: 'info',
|
|
autoCloseMs: null,
|
|
})
|
|
|
|
try {
|
|
const job = await import_instance(
|
|
ctx?.launcherType ?? inst.launcherType,
|
|
inst.compatibleMode ? inst.basePath : (ctx?.basePath ?? inst.path),
|
|
inst.name,
|
|
choice?.symlink ?? (Array.isArray(choices) ? false : choices),
|
|
resolvedInstancePath(inst),
|
|
inst.compatibleMode ? undefined : (choice?.gameVersion ?? undefined),
|
|
inst.compatibleMode ? undefined : (choice?.loader ?? undefined),
|
|
inst.compatibleMode ? undefined : (choice?.loaderVersion ?? undefined),
|
|
choice?.gameDirOverride ?? null,
|
|
)
|
|
await wait_for_install_job(job.job_id)
|
|
completed++
|
|
} catch (e) {
|
|
failedCount++
|
|
addNotification({
|
|
title: formatMessage(messages.dropImportFailedTitle),
|
|
text: formatMessage(messages.dropImportFailedText, {
|
|
name: inst.name,
|
|
error: String(e),
|
|
}),
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
cleanupLauncherZipTemp()
|
|
|
|
notificationManager.removeNotification(progressNotif.id)
|
|
if (failedCount === 0) {
|
|
addNotification({
|
|
title: formatMessage(messages.dropImportCompletedTitle),
|
|
text: formatMessage(messages.dropImportCompletedText, { count: total }),
|
|
type: 'success',
|
|
})
|
|
} else {
|
|
addNotification({
|
|
title: formatMessage(messages.dropImportCompletedTitle),
|
|
text: formatMessage(messages.dropImportCompletedPartialText, {
|
|
completed,
|
|
failed: failedCount,
|
|
total,
|
|
}),
|
|
type: 'warning',
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Batch drop flow ──────────────────────────────────────────────────
|
|
|
|
async function startBatchImport(paths: string[]) {
|
|
console.log('[BatchDrop] startBatchImport paths=', paths.length, paths)
|
|
if (batchPhase.value !== 'idle') return
|
|
batchPhase.value = 'scanning'
|
|
batchOriginalCount.value = paths.length
|
|
batchScanDone.value = 0
|
|
batchScanCancelled = false
|
|
batchInstallCancelled = false
|
|
batchConfirmIndex = 0
|
|
batchTempDirs.value = []
|
|
batchTargetInstanceId.value = ''
|
|
batchWorldPath.value = ''
|
|
batchGroups.value = []
|
|
batchCurrentGroup.value = null
|
|
batchItems.value = paths.map((path, index) => ({
|
|
id: `batch-${Date.now()}-${index}`,
|
|
sourcePath: path,
|
|
name: path.split(/[/\\]/).pop() || path,
|
|
scanState: 'pending',
|
|
selected: true,
|
|
}))
|
|
try {
|
|
await runBatchScan()
|
|
} catch (error) {
|
|
await failBatch(error)
|
|
}
|
|
}
|
|
|
|
async function runBatchScan() {
|
|
const items = batchItems.value
|
|
const total = items.length
|
|
let cursor = 0
|
|
console.log(`[BatchDrop] runBatchScan total=${total}`)
|
|
const workers = Array.from({ length: Math.min(3, total) }, async () => {
|
|
while (!batchScanCancelled) {
|
|
const index = cursor++
|
|
if (index >= total) return
|
|
const item = items[index]
|
|
item.scanState = 'scanning'
|
|
console.log(`[BatchDrop] scan start idx=${index} path=${item.sourcePath}`)
|
|
try {
|
|
const raw = await classifyDropPath(item.sourcePath)
|
|
const resolved = resolveBatchClassification(raw)
|
|
if (resolved.item_type === 'shortcut_resolved') {
|
|
item.scanState = 'skipped'
|
|
item.reason = 'shortcut-exceeded'
|
|
} else {
|
|
await applyBatchClassification(item, resolved)
|
|
}
|
|
console.log(
|
|
`[BatchDrop] scan done idx=${index} type=${item.itemType} state=${item.scanState}`,
|
|
)
|
|
} catch (error) {
|
|
item.scanState = 'error'
|
|
item.reason = error instanceof Error ? error.message : String(error)
|
|
console.log(`[BatchDrop] scan ERROR idx=${index}`, error)
|
|
} finally {
|
|
batchScanDone.value++
|
|
}
|
|
}
|
|
})
|
|
await Promise.all(workers)
|
|
if (batchScanCancelled) {
|
|
await cancelBatch('scan-cancelled-flag')
|
|
return
|
|
}
|
|
await finishBatchScan()
|
|
}
|
|
|
|
async function applyBatchClassification(item: BatchDropItem, resolved: ClassificationResult) {
|
|
console.log(
|
|
'[BatchDrop] applyBatchClassification item=',
|
|
item.name,
|
|
'resolved=',
|
|
resolved.item_type,
|
|
)
|
|
if (resolved.item_type === 'unknown') {
|
|
item.scanState = 'skipped'
|
|
item.reason = resolved.reason ?? 'unknown'
|
|
return
|
|
}
|
|
|
|
if (resolved.item_type === 'launcher' || resolved.item_type === 'hmcl_launcher') {
|
|
await expandBatchLauncher(item, resolved)
|
|
return
|
|
}
|
|
|
|
item.scanState = 'done'
|
|
item.classification = resolved
|
|
item.innerBase = (resolved as { innerBase?: string }).innerBase
|
|
|
|
switch (resolved.item_type) {
|
|
case 'mod':
|
|
case 'shader_pack':
|
|
case 'world_save':
|
|
case 'litematic':
|
|
item.itemType = resolved.item_type
|
|
break
|
|
case 'modpack':
|
|
item.itemType = 'modpack'
|
|
break
|
|
case 'resource_pack': {
|
|
const candidates = (resolved as { candidates?: string[] }).candidates ?? []
|
|
if (candidates.length === 1) {
|
|
item.itemType = candidates[0]
|
|
} else if (candidates.length > 1) {
|
|
item.itemType = 'ambiguous'
|
|
item.candidates = candidates
|
|
} else {
|
|
item.itemType = 'resource_pack'
|
|
}
|
|
break
|
|
}
|
|
case 'multiple':
|
|
item.itemType = 'ambiguous'
|
|
item.choices = (resolved as { choices?: Array<{ itemType: string }> }).choices
|
|
item.candidates = [
|
|
...new Set(
|
|
((resolved as { choices?: Array<{ itemType: string }> }).choices ?? [])
|
|
.map((choice) => choice.itemType)
|
|
.filter(Boolean),
|
|
),
|
|
]
|
|
break
|
|
}
|
|
}
|
|
|
|
async function expandBatchLauncher(item: BatchDropItem, resolved: ClassificationResult) {
|
|
const launcherType =
|
|
resolved.item_type === 'hmcl_launcher'
|
|
? 'HMCL'
|
|
: ((resolved as { launcher_type?: string }).launcher_type ?? 'Generic')
|
|
const rawBasePath =
|
|
resolved.item_type === 'hmcl_launcher'
|
|
? ((resolved as { launcher_dir?: string }).launcher_dir ?? '')
|
|
: ((resolved as { base_path?: string }).base_path ?? '')
|
|
|
|
if (!rawBasePath) {
|
|
item.scanState = 'skipped'
|
|
item.reason = 'No launcher path'
|
|
return
|
|
}
|
|
|
|
let scanBasePath = rawBasePath
|
|
const fromZip = isZipPath(rawBasePath)
|
|
if (fromZip) {
|
|
try {
|
|
const tempDir = await extractZipToTemp(rawBasePath)
|
|
batchTempDirs.value.push(tempDir)
|
|
const innerBase = (resolved as { innerBase?: string }).innerBase
|
|
scanBasePath = innerBase ? await join(tempDir, innerBase) : tempDir
|
|
} catch (error) {
|
|
item.scanState = 'error'
|
|
item.reason = error instanceof Error ? error.message : String(error)
|
|
return
|
|
}
|
|
}
|
|
|
|
try {
|
|
const results = await scanLauncherInstances(launcherType, scanBasePath)
|
|
const instances = results.flatMap((result) => result.instances)
|
|
console.log(
|
|
`[BatchDrop] expand launcher type=${launcherType} base=${scanBasePath} instances=${instances.length}`,
|
|
)
|
|
if (instances.length === 0) {
|
|
item.scanState = 'skipped'
|
|
item.reason = 'No importable instances found'
|
|
return
|
|
}
|
|
item.scanState = 'done'
|
|
item.itemType = 'launcher_container'
|
|
for (const inst of instances) {
|
|
const compatible = inst.compatibleMode
|
|
batchItems.value.push({
|
|
id: `batch-${Date.now()}-${batchItems.value.length}`,
|
|
sourcePath: item.sourcePath,
|
|
name: inst.name,
|
|
sourceLabel: item.name,
|
|
scanState: 'done',
|
|
itemType: 'instance',
|
|
launcherType,
|
|
basePath: compatible ? inst.path : scanBasePath,
|
|
instanceFolder: inst.name,
|
|
instancePath: compatible ? inst.versionPath : undefined,
|
|
fromZip: fromZip || undefined,
|
|
selected: true,
|
|
})
|
|
}
|
|
} catch (error) {
|
|
item.scanState = 'error'
|
|
item.reason = error instanceof Error ? error.message : String(error)
|
|
}
|
|
}
|
|
|
|
async function finishBatchScan() {
|
|
const hasImportable = batchItems.value.some(
|
|
(item) =>
|
|
item.itemType && item.itemType !== 'launcher_container' && item.scanState === 'done',
|
|
)
|
|
console.log('[BatchDrop] finishBatchScan hasImportable=', hasImportable)
|
|
if (!hasImportable) {
|
|
await showBatchSummaryFromScan()
|
|
return
|
|
}
|
|
|
|
const needsInstance = batchItems.value.some(
|
|
(item) =>
|
|
item.itemType &&
|
|
[
|
|
'mod',
|
|
'resource_pack',
|
|
'shader_pack',
|
|
'world_save',
|
|
'litematic',
|
|
'data_pack',
|
|
'ambiguous',
|
|
].includes(item.itemType),
|
|
)
|
|
if (needsInstance) {
|
|
if (isInInstance.value && instanceId.value) {
|
|
console.log('[BatchDrop] in-instance context, using current instance directly')
|
|
await proceedBatchWithTargetInstance(instanceId.value)
|
|
return
|
|
}
|
|
console.log('[BatchDrop] finishBatchScan needsInstance=true (show target instance picker)')
|
|
batchPhase.value = 'picking-instance'
|
|
await loadBatchTargetInstances()
|
|
return
|
|
}
|
|
|
|
console.log('[BatchDrop] finishBatchScan needsInstance=false (go to group confirms)')
|
|
await startBatchGroupConfirms()
|
|
}
|
|
|
|
async function proceedBatchWithTargetInstance(targetInstanceId: string) {
|
|
batchTargetInstanceId.value = targetInstanceId
|
|
const needsWorld = batchItems.value.some(
|
|
(item) =>
|
|
item.itemType === 'data_pack' ||
|
|
(item.itemType === 'ambiguous' && (item.candidates ?? []).includes('data_pack')),
|
|
)
|
|
if (needsWorld) {
|
|
batchPhase.value = 'picking-world'
|
|
batchWorldMode = true
|
|
dataPackWorldModal.value?.show(targetInstanceId)
|
|
console.log('[BatchDrop] dataPackWorldModal.show (batch world) called')
|
|
} else {
|
|
await startBatchGroupConfirms()
|
|
}
|
|
}
|
|
|
|
async function loadBatchTargetInstances() {
|
|
try {
|
|
const all = await listInstances()
|
|
batchTargetInstances.value = all.map((inst) => ({
|
|
id: inst.id,
|
|
name: inst.name,
|
|
iconUrl: getDisplayInstanceIcon(inst.icon_path, inst.loader).url,
|
|
gameVersion: inst.game_version || null,
|
|
loader: inst.loader || null,
|
|
}))
|
|
console.log('[BatchDrop] loadBatchTargetInstances count=', batchTargetInstances.value.length)
|
|
} catch (error) {
|
|
batchTargetInstances.value = []
|
|
console.log('[BatchDrop] loadBatchTargetInstances ERROR', error)
|
|
}
|
|
await nextTick()
|
|
genericInstallModal.value?.show({
|
|
contentType: 'mod',
|
|
fileName: formatMessage(messages.dropBatchTargetLabel),
|
|
instances: batchTargetInstances.value,
|
|
})
|
|
batchTargetPickMode = true
|
|
console.log('[BatchDrop] genericInstallModal.show (batch target) called')
|
|
}
|
|
|
|
async function handleBatchOrDatapackWorldSelect(target: {
|
|
instanceId: string
|
|
worldPath: string
|
|
}) {
|
|
if (batchWorldMode) {
|
|
batchWorldMode = false
|
|
batchTargetInstanceId.value = target.instanceId
|
|
batchWorldPath.value = target.worldPath
|
|
console.log('[BatchDrop] batch world selected world=', target.worldPath)
|
|
try {
|
|
await startBatchGroupConfirms()
|
|
} catch (error) {
|
|
await failBatch(error)
|
|
}
|
|
return
|
|
}
|
|
await handleDatapackWorldSelect(target)
|
|
}
|
|
|
|
function handleBatchWorldAfterHide() {
|
|
if (batchWorldMode) {
|
|
batchWorldMode = false
|
|
void cancelBatch('world-cancel')
|
|
}
|
|
}
|
|
|
|
async function startBatchGroupConfirms() {
|
|
const groups: BatchDropGroup[] = []
|
|
const typeOrder = [
|
|
'modpack',
|
|
'ambiguous',
|
|
'mod',
|
|
'resource_pack',
|
|
'shader_pack',
|
|
'world_save',
|
|
'litematic',
|
|
'data_pack',
|
|
'instance',
|
|
]
|
|
const byType = new Map<string, BatchDropItem[]>()
|
|
for (const item of batchItems.value) {
|
|
if (item.selected === false || !item.itemType || item.itemType === 'launcher_container')
|
|
continue
|
|
if (item.scanState !== 'done') continue
|
|
const list = byType.get(item.itemType) ?? []
|
|
list.push(item)
|
|
byType.set(item.itemType, list)
|
|
}
|
|
for (const type of typeOrder) {
|
|
const list = byType.get(type)
|
|
if (list?.length) groups.push({ id: type, type, items: list })
|
|
}
|
|
batchGroups.value = groups
|
|
batchConfirmIndex = 0
|
|
batchPhase.value = 'confirming'
|
|
console.log(
|
|
'[BatchDrop] startBatchGroupConfirms groups=',
|
|
groups.map((g) => `${g.type}:${g.items.length}`),
|
|
)
|
|
await showNextBatchGroup()
|
|
}
|
|
|
|
async function showNextBatchGroup() {
|
|
try {
|
|
if (batchConfirmIndex >= batchGroups.value.length) {
|
|
console.log('[BatchDrop] showNextBatchGroup done all groups, run install')
|
|
await runBatchInstall()
|
|
return
|
|
}
|
|
const group = batchGroups.value[batchConfirmIndex]
|
|
batchCurrentGroup.value = group
|
|
console.log(
|
|
`[BatchDrop] showNextBatchGroup idx=${batchConfirmIndex} type=${group.type} count=${group.items.length}`,
|
|
)
|
|
if (group.type === 'instance') {
|
|
if (group.items.some((item) => item.fromZip)) {
|
|
for (const item of group.items) item.symlink = false
|
|
batchConfirmIndex++
|
|
await showNextBatchGroup()
|
|
return
|
|
}
|
|
const cap = await check_symlink_capability()
|
|
batchSymlinkMode = true
|
|
symlinkCardsModal.value?.show({
|
|
instances: group.items.map((item) => ({
|
|
name: item.name,
|
|
path: item.instancePath,
|
|
launcherType: item.launcherType,
|
|
basePath: item.basePath,
|
|
})),
|
|
symlinkCapable: cap,
|
|
})
|
|
} else {
|
|
await showBatchGroupConfirmModal(group)
|
|
}
|
|
} catch (error) {
|
|
await failBatch(error)
|
|
}
|
|
}
|
|
|
|
async function showBatchGroupConfirmModal(group: BatchDropGroup) {
|
|
batchGroupKey.value++
|
|
await nextTick()
|
|
|
|
let classification: ClassificationResult
|
|
if (group.type === 'ambiguous') {
|
|
const choices = group.items
|
|
.flatMap((item) =>
|
|
item.choices?.length
|
|
? item.choices
|
|
: (item.candidates ?? []).map((candidate) => ({ itemType: candidate })),
|
|
)
|
|
.filter(
|
|
(choice, index, all) => all.findIndex((c) => c.itemType === choice.itemType) === index,
|
|
)
|
|
classification = {
|
|
item_type: 'multiple',
|
|
file_path: group.items[0]?.sourcePath,
|
|
choices,
|
|
} as unknown as ClassificationResult
|
|
} else {
|
|
classification = { item_type: group.type } as unknown as ClassificationResult
|
|
}
|
|
dropClassification.value = classification
|
|
dropFileName.value = formatMessage(messages.dropBatchGroupFileLabel, {
|
|
count: group.items.length,
|
|
names: group.items.map((item) => item.name).join(', '),
|
|
})
|
|
batchGroupMode = true
|
|
confirmDropModal.value?.show()
|
|
}
|
|
|
|
function onBatchGroupConfirm(type: string) {
|
|
const group = batchCurrentGroup.value
|
|
if (!group) return
|
|
console.log('[BatchDrop] onBatchGroupConfirm group=', group.type, 'type=', type)
|
|
for (const item of group.items) {
|
|
item.selected = true
|
|
item.confirmedType = type
|
|
item.itemType = type
|
|
const choice = item.choices?.find((c) => c.itemType === type)
|
|
if (choice?.innerBase !== undefined) {
|
|
item.innerBase = choice.innerBase
|
|
}
|
|
}
|
|
batchConfirmIndex++
|
|
void showNextBatchGroup()
|
|
}
|
|
|
|
function cancelBatchScan() {
|
|
console.log('[BatchDrop] cancelBatchScan clicked')
|
|
batchScanCancelled = true
|
|
void cancelBatch('scan-button')
|
|
}
|
|
|
|
async function cancelBatch(source = 'unknown') {
|
|
console.log(
|
|
`[BatchDrop] cancelBatch INVOKED source=${source} phase=${batchPhase.value}`,
|
|
new Error(source).stack,
|
|
)
|
|
if (batchPhase.value === 'idle' || batchPhase.value === 'cancelled') return
|
|
batchScanCancelled = true
|
|
batchInstallCancelled = true
|
|
batchSymlinkMode = false
|
|
batchTargetPickMode = false
|
|
batchWorldMode = false
|
|
batchGroupMode = false
|
|
confirmDropModal.value?.hide()
|
|
symlinkCardsModal.value?.hide()
|
|
await cleanupBatchTempDirs()
|
|
addNotification({
|
|
title: formatMessage(messages.dropImportCancelledTitle),
|
|
text: formatMessage(messages.dropImportCancelledText),
|
|
type: 'info',
|
|
})
|
|
finishBatch()
|
|
batchPhase.value = 'idle'
|
|
}
|
|
|
|
async function failBatch(error: unknown) {
|
|
console.error('[BatchDrop] batch failed', error)
|
|
addNotification({
|
|
title: formatMessage(messages.dropImportFailedTitle),
|
|
text: error instanceof Error ? error.message : String(error),
|
|
type: 'error',
|
|
})
|
|
batchPhase.value = 'idle'
|
|
batchScanCancelled = true
|
|
batchInstallCancelled = true
|
|
batchTargetPickMode = false
|
|
batchWorldMode = false
|
|
batchGroupMode = false
|
|
confirmDropModal.value?.hide()
|
|
symlinkCardsModal.value?.hide()
|
|
await cleanupBatchTempDirs()
|
|
finishBatch()
|
|
}
|
|
|
|
async function cleanupBatchTempDirs() {
|
|
const dirs = [...batchTempDirs.value]
|
|
batchTempDirs.value = []
|
|
for (const dir of dirs) {
|
|
try {
|
|
await removeTempDir(dir)
|
|
} catch {
|
|
// Best-effort cleanup; stale dirs are swept by the backend.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function showBatchSummaryFromScan() {
|
|
const skipped = batchItems.value.filter(
|
|
(item) => item.scanState === 'skipped' || item.scanState === 'error',
|
|
)
|
|
if (skipped.length > 0) {
|
|
addNotification({
|
|
title: formatMessage(messages.dropBatchNothingImportableTitle),
|
|
text: formatMessage(messages.dropBatchNothingImportableText, {
|
|
count: skipped.length,
|
|
}),
|
|
type: 'warning',
|
|
})
|
|
}
|
|
await finishBatchImport()
|
|
}
|
|
|
|
async function runBatchInstall() {
|
|
batchPhase.value = 'installing'
|
|
batchInstallCancelled = false
|
|
console.log('[BatchDrop] runBatchInstall start')
|
|
const typeOrder = [
|
|
'modpack',
|
|
'instance',
|
|
'mod',
|
|
'resource_pack',
|
|
'shader_pack',
|
|
'world_save',
|
|
'litematic',
|
|
'data_pack',
|
|
]
|
|
const queue = typeOrder.flatMap((type) =>
|
|
batchItems.value.filter(
|
|
(item) => item.selected !== false && item.scanState === 'done' && item.itemType === type,
|
|
),
|
|
)
|
|
for (const item of batchItems.value) {
|
|
if (
|
|
item.selected !== false &&
|
|
item.scanState === 'done' &&
|
|
item.itemType &&
|
|
!typeOrder.includes(item.itemType)
|
|
) {
|
|
queue.push(item)
|
|
}
|
|
}
|
|
|
|
let succeeded = 0
|
|
let failed = 0
|
|
let skipped = 0
|
|
for (const item of queue) {
|
|
if (batchInstallCancelled) {
|
|
addNotification({
|
|
title: formatMessage(messages.dropImportCancelledTitle),
|
|
text: formatMessage(messages.dropImportCancelledText),
|
|
type: 'info',
|
|
})
|
|
break
|
|
}
|
|
item.installState = 'processing'
|
|
console.log(`[BatchDrop] install start type=${item.itemType} name=${item.name}`)
|
|
try {
|
|
await installBatchItem(item)
|
|
if (pendingDropIncompatibility.value) {
|
|
const installed = await new Promise<boolean>((resolve) => {
|
|
batchCompatResolve = resolve
|
|
})
|
|
if (installed) {
|
|
item.installState = 'success'
|
|
succeeded++
|
|
console.log(`[BatchDrop] install SUCCESS (compat) name=${item.name}`)
|
|
} else {
|
|
item.installState = 'skipped'
|
|
skipped++
|
|
console.log(`[BatchDrop] install SKIPPED (compat cancelled) name=${item.name}`)
|
|
}
|
|
} else {
|
|
item.installState = 'success'
|
|
succeeded++
|
|
console.log(`[BatchDrop] install SUCCESS name=${item.name}`)
|
|
}
|
|
} catch (error) {
|
|
item.installState = 'failed'
|
|
failed++
|
|
console.log(`[BatchDrop] install FAILED name=${item.name}`, error)
|
|
}
|
|
}
|
|
console.log(
|
|
`[BatchDrop] runBatchInstall finished queue=${queue.length} success=${succeeded} failed=${failed} skipped=${skipped}`,
|
|
)
|
|
|
|
addNotification({
|
|
title: formatMessage(messages.dropBatchCompletedTitle),
|
|
text: formatMessage(messages.dropBatchCompletedText, {
|
|
succeeded,
|
|
failed,
|
|
skipped,
|
|
total: queue.length,
|
|
}),
|
|
type: failed > 0 || skipped > 0 ? 'warning' : 'success',
|
|
})
|
|
await finishBatchImport()
|
|
}
|
|
|
|
async function installBatchItem(item: BatchDropItem) {
|
|
console.log(
|
|
`[BatchDrop] installBatchItem type=${item.itemType} name=${item.name} path=${item.sourcePath}`,
|
|
)
|
|
switch (item.itemType) {
|
|
case 'modpack':
|
|
await installModpackFromPath(item.sourcePath, item.name, { persistUntilDone: false })
|
|
return
|
|
case 'instance': {
|
|
const job = await import_instance(
|
|
item.launcherType,
|
|
item.basePath,
|
|
item.instanceFolder,
|
|
item.symlink ?? false,
|
|
item.instancePath,
|
|
item.gameVersion ?? undefined,
|
|
item.loader ?? undefined,
|
|
item.loaderVersion ?? undefined,
|
|
item.gameDirOverride ?? null,
|
|
)
|
|
await wait_for_install_job(job.job_id)
|
|
return
|
|
}
|
|
case 'data_pack':
|
|
if (!batchTargetInstanceId.value || !batchWorldPath.value) {
|
|
throw new Error('Missing target instance or world for datapack')
|
|
}
|
|
await install_datapack_to_world(
|
|
batchTargetInstanceId.value,
|
|
batchWorldPath.value,
|
|
item.sourcePath,
|
|
item.innerBase,
|
|
)
|
|
return
|
|
case 'mod':
|
|
case 'resource_pack':
|
|
case 'shader_pack':
|
|
case 'world_save':
|
|
case 'litematic':
|
|
case 'schematic':
|
|
await installContentDirectly(
|
|
item.itemType,
|
|
item.sourcePath,
|
|
batchTargetInstanceId.value,
|
|
item.innerBase,
|
|
)
|
|
return
|
|
default:
|
|
throw new Error(`Unsupported batch item type: ${item.itemType}`)
|
|
}
|
|
}
|
|
|
|
async function finishBatchImport() {
|
|
await cleanupBatchTempDirs()
|
|
batchPhase.value = 'idle'
|
|
batchItems.value = []
|
|
finishBatch()
|
|
}
|
|
|
|
// ── Incompatibility warning handlers ─────────────────────────────────
|
|
|
|
async function handleIncompatibilityWarningUpdate(
|
|
version: { id: string; project_id: string },
|
|
_event: MouseEvent,
|
|
) {
|
|
const decision = batchCompatResolve
|
|
batchCompatResolve = null
|
|
const pending = pendingDropIncompatibility.value
|
|
if (pending) {
|
|
pendingDropIncompatibility.value = null
|
|
const projectType = contentFileProjectTypeMap[pending.type]
|
|
try {
|
|
await add_project_from_path(pending.instId, pending.filePath, projectType)
|
|
addNotification({
|
|
title: formatMessage(messages.dropContentInstalledTitle),
|
|
text: formatMessage(messages.dropContentInstalledText),
|
|
type: 'success',
|
|
})
|
|
} catch (e) {
|
|
addNotification({
|
|
title: formatMessage(messages.dropInstallFailedTitle),
|
|
text: e instanceof Error ? e.message : typeof e === 'string' ? e : JSON.stringify(e),
|
|
type: 'error',
|
|
})
|
|
}
|
|
decision?.(true)
|
|
return
|
|
}
|
|
// Delegate to content install provider for normal flow
|
|
await contentInstall.install(version.project_id, version.id, null, 'IncompatibilityWarning')
|
|
}
|
|
|
|
function handleIncompatibilityWarningCancel() {
|
|
const decision = batchCompatResolve
|
|
batchCompatResolve = null
|
|
pendingDropIncompatibility.value = null
|
|
if (decision) {
|
|
decision(false)
|
|
return
|
|
}
|
|
}
|
|
|
|
function handleDropInstallSearchCompat() {
|
|
const decision = batchCompatResolve
|
|
batchCompatResolve = null
|
|
const pending = pendingDropIncompatibility.value
|
|
if (!pending) return
|
|
const searchName = pending.meta?.name ?? pending.meta?.mod_id ?? 'mod'
|
|
const searchUrl = pending.modrinthLookup
|
|
? `/project/${pending.modrinthLookup.project_id}`
|
|
: `/browse/mod?q=${encodeURIComponent(searchName)}&i=${pending.instId}`
|
|
pendingDropIncompatibility.value = null
|
|
decision?.(false)
|
|
router.push(searchUrl)
|
|
}
|
|
|
|
// ── Global drop setup ────────────────────────────────────────────────
|
|
|
|
const { isDragging, isProcessing, finishBatch } = useGlobalDrop(
|
|
{
|
|
classifyFile: classifyDropPath,
|
|
onClassifyStart: (fileName) => {
|
|
if (onSkinsPage.value) return
|
|
if (onSchematicWorkshopPage.value && isSchematicFile(fileName)) return
|
|
dropProcessingNotificationId.value = addNotification({
|
|
title: formatMessage(messages.dropProcessing, { name: fileName }),
|
|
type: 'info',
|
|
autoCloseMs: null,
|
|
}).id
|
|
},
|
|
onImportStart: (type, classification) => {
|
|
if (type === 'unknown' && classification?.reason === 'skipped') return
|
|
dropClassification.value = classification
|
|
dropFilePath.value =
|
|
classification.file_path ?? classification.base_path ?? lastDroppedPath.value
|
|
dropFileName.value =
|
|
classification.file_path?.split(/[/\\]/).pop() ??
|
|
classification.base_path?.split(/[/\\]/).pop() ??
|
|
(lastDroppedPath.value.split(/[/\\]/).pop() || 'file')
|
|
|
|
if (type === 'unknown' && classification?.reason?.toLowerCase().includes('nested')) {
|
|
clearDropProcessingNotification()
|
|
showNestedUnpackPrompt(classification)
|
|
return
|
|
}
|
|
|
|
if (type === 'unknown' && classification?.reason?.toLowerCase().includes('extraction')) {
|
|
clearDropProcessingNotification()
|
|
showForceAnalysisPrompt(classification)
|
|
return
|
|
}
|
|
|
|
if (type === 'unknown') {
|
|
clearDropProcessingNotification()
|
|
const unknownFile =
|
|
classification?.file_path?.split(/[/\\]/).pop() ??
|
|
classification?.base_path?.split(/[/\\]/).pop() ??
|
|
''
|
|
|
|
const isTempFile = unknownFile.startsWith('.tmp') || unknownFile.startsWith('tmp')
|
|
if (isTempFile) {
|
|
addNotification({
|
|
title: formatMessage(messages.dropTemporaryFileTitle),
|
|
text: formatMessage(messages.dropTemporaryFileText, {
|
|
file: unknownFile,
|
|
}),
|
|
type: 'warning',
|
|
})
|
|
} else {
|
|
addNotification({
|
|
title: formatMessage(messages.dropUnknownTitle),
|
|
text: unknownReasonMessage(classification?.reason),
|
|
type: 'error',
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
confirmDropModal.value?.show()
|
|
},
|
|
onImportEnd: () => {},
|
|
onBatchStart: (paths) => {
|
|
void startBatchImport(paths)
|
|
},
|
|
onError: (reason) => {
|
|
clearDropProcessingNotification()
|
|
|
|
if (reason === 'multiple-files') {
|
|
addNotification({
|
|
title: formatMessage(messages.dropMultipleFilesTitle),
|
|
text: formatMessage(messages.dropMultipleFilesText),
|
|
type: 'error',
|
|
})
|
|
} else if (reason === 'shortcut-exceeded') {
|
|
addNotification({
|
|
title: formatMessage(messages.dropShortcutFailedTitle),
|
|
text: formatMessage(messages.dropShortcutFailedText),
|
|
type: 'error',
|
|
})
|
|
} else if (reason === 'unknown') {
|
|
addNotification({
|
|
title: formatMessage(messages.dropUnknownTitle),
|
|
text: formatMessage(messages.dropUnknownText),
|
|
type: 'error',
|
|
})
|
|
} else {
|
|
addNotification({
|
|
title: formatMessage(messages.dropErrorTitle),
|
|
text: reason,
|
|
type: 'error',
|
|
})
|
|
}
|
|
},
|
|
},
|
|
fileDrop,
|
|
)
|
|
|
|
// ── Return public API ────────────────────────────────────────────────
|
|
|
|
return {
|
|
// State
|
|
isDragging,
|
|
isProcessing,
|
|
batchActive,
|
|
batchPhase,
|
|
batchItems,
|
|
batchOriginalCount,
|
|
batchScanDone,
|
|
batchGroups,
|
|
batchCurrentGroup,
|
|
batchTargetInstances,
|
|
batchTargetInstanceId,
|
|
batchWorldPath,
|
|
dropClassification,
|
|
dropFileName,
|
|
dropFilePath,
|
|
dropProcessingNotificationId,
|
|
scanningInstances,
|
|
pendingInstall,
|
|
pendingDropIncompatibility,
|
|
selectedInstances,
|
|
currentImportContext,
|
|
compatibleModeResults,
|
|
compatibleModeGameDir,
|
|
compatibleModeLauncherType,
|
|
contentInstallIncompatibilityWarningVersions,
|
|
contentInstallIncompatibilityWarningCurrentGameVersion,
|
|
contentInstallIncompatibilityWarningCurrentLoader,
|
|
contentInstallIncompatibilityWarningProjectType,
|
|
contentInstallIncompatibilityWarningProjectIconUrl,
|
|
contentInstallIncompatibilityWarningProjectName,
|
|
contentInstallIncompatibilityWarningMessage,
|
|
contentInstallIncompatibilityWarningInstalling,
|
|
batchGroupKey,
|
|
incompatWarningKey,
|
|
|
|
// Modal refs (to be bound in template)
|
|
confirmDropModal,
|
|
genericInstallModal,
|
|
launcherImportModal,
|
|
symlinkCardsModal,
|
|
dataPackWorldModal,
|
|
compatibleModeConfirmModal,
|
|
incompatibilityWarningModal,
|
|
|
|
// Handlers
|
|
handleConfirmDropCancel,
|
|
handleConfirmDropConfirm,
|
|
handleConfirmDropHelp,
|
|
handleCompatibleModeConfirm,
|
|
handleDropConfirm,
|
|
handleDropCancel,
|
|
handleDropHelp,
|
|
handleGenericInstall,
|
|
handleGenericInstallCancel,
|
|
handleGenericInstallNavigateCreate,
|
|
handleBatchOrDatapackWorldSelect,
|
|
handleBatchWorldAfterHide,
|
|
onLauncherImportCancelled,
|
|
onImportSelected,
|
|
onSymlinkMethodCancelled,
|
|
onSymlinkMethodConfirmed,
|
|
chooseImportMethod,
|
|
cancelBatchScan,
|
|
handleIncompatibilityWarningUpdate,
|
|
handleIncompatibilityWarningCancel,
|
|
handleDropInstallSearchCompat,
|
|
|
|
// Utility
|
|
contentFileProjectTypeMap,
|
|
clearDropProcessingNotification,
|
|
showNestedUnpackPrompt,
|
|
showForceAnalysisPrompt,
|
|
unknownReasonMessage,
|
|
}
|
|
}
|
|
|
|
// Need to import defineMessages from vue-i18n or similar
|
|
function defineMessages<T extends Record<string, { id: string; defaultMessage: string }>>(
|
|
messages: T,
|
|
): T {
|
|
return messages
|
|
}
|