Files
Starlight_Launcher/apps/app-frontend/src/providers/setup/creation-modal.ts
T
Disy bc904065c3
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
feat: complete hosted mod sync and launcher interface updates
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
2026-09-15 19:06:56 +08:00

447 lines
14 KiB
TypeScript

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