Files
Starlight_Lancher/apps/app-frontend/src/composables/useDropImport.ts
Disy920 fa96226beb
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
refactor: remove telemetry and fix launcher workflows
2026-09-13 20:32:57 +08:00

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
}