feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
|
||||
import { computed, type Ref, ref, watchEffect } from 'vue'
|
||||
|
||||
type AppCredentials = {
|
||||
session?: string | null
|
||||
user?: Labrinth.Users.v2.User | null
|
||||
}
|
||||
|
||||
export function setupAuthProvider(
|
||||
credentials: Ref<AppCredentials | null | undefined>,
|
||||
requestSignIn: (redirectPath: string) => void | Promise<void>,
|
||||
) {
|
||||
const sessionToken = ref<string | null>(null)
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const isReady = computed(() => credentials.value !== undefined)
|
||||
|
||||
const authProvider: AuthProvider = {
|
||||
session_token: sessionToken,
|
||||
user,
|
||||
isReady,
|
||||
requestSignIn,
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
sessionToken.value = credentials.value?.session ?? null
|
||||
user.value = credentials.value?.user ?? null
|
||||
})
|
||||
|
||||
provideAuth(authProvider)
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
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 { trackEvent } from '@/helpers/analytics'
|
||||
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)
|
||||
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
trackEvent('InstanceCreate', { source: 'CreationModalImport' })
|
||||
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)
|
||||
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
|
||||
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)
|
||||
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModal',
|
||||
})
|
||||
} 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { provideFileDrop } from '@modrinth/ui'
|
||||
import type { DragDropEvent } from '@tauri-apps/api/webview'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
|
||||
function toLogicalPosition(position: { x: number; y: number }) {
|
||||
const scale = window.devicePixelRatio || 1
|
||||
return {
|
||||
x: position.x / scale,
|
||||
y: position.y / scale,
|
||||
}
|
||||
}
|
||||
|
||||
export function setupFileDropProvider() {
|
||||
let nativeFileDropPaths: string[] = []
|
||||
let nativeFileDropActive = false
|
||||
let internalDragActive = false
|
||||
|
||||
// Tauri emits drag-drop events for HTML drags as well as files from the OS.
|
||||
// Track document drag sources so moving launcher controls cannot activate the
|
||||
// global file-import overlay.
|
||||
window.addEventListener('dragstart', () => {
|
||||
internalDragActive = true
|
||||
})
|
||||
window.addEventListener('dragend', () => {
|
||||
internalDragActive = false
|
||||
})
|
||||
|
||||
const provider = {
|
||||
async listenNativeFileDrop(handler) {
|
||||
return await getCurrentWebview().onDragDropEvent((event: { payload: DragDropEvent }) => {
|
||||
const payload = event.payload
|
||||
if (internalDragActive) return
|
||||
|
||||
if (payload.type === 'leave') {
|
||||
if (!nativeFileDropActive) return
|
||||
nativeFileDropActive = false
|
||||
nativeFileDropPaths = []
|
||||
void handler({
|
||||
type: 'leave',
|
||||
paths: [],
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type === 'enter') {
|
||||
if (!payload.paths?.length) return
|
||||
nativeFileDropPaths = payload.paths
|
||||
nativeFileDropActive = true
|
||||
} else if (payload.type === 'drop' && payload.paths?.length) {
|
||||
if (!nativeFileDropActive) return
|
||||
nativeFileDropPaths = payload.paths
|
||||
} else if (!nativeFileDropActive) {
|
||||
return
|
||||
}
|
||||
|
||||
void handler({
|
||||
type: payload.type,
|
||||
paths: nativeFileDropPaths,
|
||||
position: toLogicalPosition(payload.position),
|
||||
})
|
||||
|
||||
if (payload.type === 'drop') {
|
||||
nativeFileDropActive = false
|
||||
nativeFileDropPaths = []
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
provideFileDrop(provider)
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type PickedFile, provideFilePicker } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { readFile } from '@tauri-apps/plugin-fs'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import { getLoaderInstanceIcon } from '@/helpers/instance-icons'
|
||||
|
||||
function getFileName(path: string, fallback: string) {
|
||||
return path.split(/[\\/]/).pop() || fallback
|
||||
}
|
||||
|
||||
function getDialogPath(result: string | { path?: string } | null | undefined) {
|
||||
if (!result) return null
|
||||
return typeof result === 'string' ? result : (result.path ?? null)
|
||||
}
|
||||
|
||||
async function createFileFromPath(path: string, fallbackName: string, type?: string) {
|
||||
const bytes = await readFile(path)
|
||||
const name = getFileName(path, fallbackName)
|
||||
return new File([bytes], name, type ? { type } : undefined)
|
||||
}
|
||||
|
||||
export async function pickImage(): Promise<PickedFile | null> {
|
||||
const result = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: 'Image', extensions: ['png', 'jpeg', 'jpg', 'svg', 'webp', 'gif'] }],
|
||||
})
|
||||
if (!result) return null
|
||||
const path = getDialogPath(result)
|
||||
if (!path) return null
|
||||
const file = await createFileFromPath(path, 'icon')
|
||||
return { file, path, previewUrl: convertFileSrc(path) }
|
||||
}
|
||||
|
||||
export function setupFilePickerProvider() {
|
||||
const instanceIconPickerModal =
|
||||
useTemplateRef<ComponentExposed<typeof InstanceIconPickerModal>>('instanceIconPickerModal')
|
||||
|
||||
provideFilePicker({
|
||||
async pickFolder() {
|
||||
const result = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
})
|
||||
const path = getDialogPath(result)
|
||||
if (!path) return null
|
||||
return { path }
|
||||
},
|
||||
pickImage,
|
||||
pickInstanceIcon: () => instanceIconPickerModal.value?.show() ?? Promise.resolve(null),
|
||||
getLoaderInstanceIconUrl: (loader) => getLoaderInstanceIcon(loader)?.url ?? null,
|
||||
async pickModpackFile(options) {
|
||||
const result = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: 'Modpack', extensions: ['mrpack', 'zip'] }],
|
||||
})
|
||||
if (!result) return null
|
||||
const path = getDialogPath(result)
|
||||
if (!path) return null
|
||||
if (options?.readFile === false) {
|
||||
// Instance imports stream from the native path, keeping large packs out of JS memory.
|
||||
return { path, previewUrl: '' }
|
||||
}
|
||||
return {
|
||||
file: await createFileFromPath(
|
||||
path,
|
||||
'modpack.mrpack',
|
||||
'application/x-modrinth-modpack+zip',
|
||||
),
|
||||
path,
|
||||
previewUrl: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return { instanceIconPickerModal }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { AbstractWebNotificationManager } from '@modrinth/ui'
|
||||
import { provideInstanceImport } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
|
||||
import { import_plan_listener } from '@/helpers/events.js'
|
||||
import {
|
||||
cancel_import_plan,
|
||||
get_default_launcher_path,
|
||||
get_importable_instances,
|
||||
import_instance,
|
||||
start_import_plan,
|
||||
} from '@/helpers/import.js'
|
||||
import { wait_for_install_job } from '@/helpers/install'
|
||||
import { get_loader_versions } from '@/helpers/metadata.js'
|
||||
import { openPath } from '@/helpers/utils.js'
|
||||
|
||||
export function setupInstanceImportProvider(notificationManager: AbstractWebNotificationManager) {
|
||||
const { handleError } = notificationManager
|
||||
|
||||
provideInstanceImport({
|
||||
async getDetectedLaunchers() {
|
||||
const launcherNames = [
|
||||
'ModrinthApp',
|
||||
'MultiMC',
|
||||
'PCL2',
|
||||
'PCL2CE',
|
||||
'HMCL',
|
||||
'GDLauncher',
|
||||
'ATLauncher',
|
||||
'Curseforge',
|
||||
'PrismLauncher',
|
||||
'Generic',
|
||||
]
|
||||
const launchers = []
|
||||
for (const name of launcherNames) {
|
||||
try {
|
||||
const path = await get_default_launcher_path(name)
|
||||
if (!path) continue
|
||||
const instances = await get_importable_instances(name, path)
|
||||
if (instances?.length > 0) {
|
||||
launchers.push({ name, path, instances })
|
||||
}
|
||||
} catch {
|
||||
// Skip launchers that fail detection
|
||||
}
|
||||
}
|
||||
return launchers
|
||||
},
|
||||
async getImportableInstances(launcherName: string, path: string) {
|
||||
return (await get_importable_instances(launcherName, path)) ?? []
|
||||
},
|
||||
async getLoaderVersions(loader: string, gameVersion: string) {
|
||||
const manifest = await get_loader_versions(loader, gameVersion)
|
||||
const gameVersions = manifest?.gameVersions ?? manifest?.game_versions ?? []
|
||||
const versionGroups = manifest?.versionGroups ?? manifest?.version_groups ?? []
|
||||
const entry = gameVersions.find(
|
||||
(version) =>
|
||||
(version.id ?? '').replace('${modrinth.gameVersion}', gameVersion) === gameVersion,
|
||||
)
|
||||
let loaders: Array<string | { id?: string }> = []
|
||||
if (entry) {
|
||||
if (entry.versionGroup) {
|
||||
loaders = versionGroups.find((group) => group.id === entry.versionGroup)?.loaders ?? []
|
||||
} else {
|
||||
loaders = entry.loaders ?? entry.loader_versions ?? []
|
||||
}
|
||||
}
|
||||
const versions = loaders.map((loaderVersion) =>
|
||||
typeof loaderVersion === 'string' ? loaderVersion : loaderVersion.id,
|
||||
)
|
||||
console.debug('[InstanceImport] loader versions', loader, gameVersion, versions.length)
|
||||
return [...new Set(versions.filter((version): version is string => !!version))]
|
||||
},
|
||||
openPath: (path) => openPath(path),
|
||||
startImportPlan: (request) => start_import_plan(request),
|
||||
cancelImportPlan: (requestId) => cancel_import_plan(requestId),
|
||||
listenImportPlan: (callback) => import_plan_listener(callback),
|
||||
async importInstances(selections) {
|
||||
for (const sel of selections) {
|
||||
for (let i = 0; i < sel.instanceNames.length; i++) {
|
||||
const instanceName = sel.instanceNames[i]
|
||||
const instancePath = sel.instancePaths?.[i]
|
||||
try {
|
||||
const job = await import_instance(
|
||||
sel.launcherType ?? sel.launcher,
|
||||
sel.path,
|
||||
instanceName,
|
||||
sel.symlink ?? false,
|
||||
instancePath,
|
||||
sel.gameVersion,
|
||||
sel.loader,
|
||||
sel.loaderVersion,
|
||||
sel.gameDirOverride ?? null,
|
||||
)
|
||||
await wait_for_install_job(job.job_id)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async selectDirectory() {
|
||||
const result = await open({ multiple: false, directory: true })
|
||||
return result?.toString() ?? null
|
||||
},
|
||||
async selectDirectories() {
|
||||
const result = await open({ multiple: true, directory: true })
|
||||
if (!result) return null
|
||||
if (Array.isArray(result)) return result.map((p) => p.toString())
|
||||
return [result.toString()]
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { LoadingStateProvider } from '@modrinth/ui'
|
||||
import { createLoadingStateCore, provideLoadingState } from '@modrinth/ui'
|
||||
|
||||
/**
|
||||
* Source of truth for the desktop app's loading state.
|
||||
*
|
||||
* Owns the token-based ref-counter directly (no Pinia store). Consumers
|
||||
* obtain the same reactive state via `injectLoadingState()` from `@modrinth/ui`.
|
||||
*
|
||||
* Returns the provider so the call site (App.vue) can also use it directly
|
||||
* without a second injection round-trip.
|
||||
*/
|
||||
export function setupLoadingStateProvider(): LoadingStateProvider {
|
||||
const provider = createLoadingStateCore({ barEnabled: false })
|
||||
provideLoadingState(provider)
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
addPendingServerContentInstalls,
|
||||
type BrowseInstallPlan,
|
||||
type BrowseSelectedProject,
|
||||
createContext,
|
||||
type CreationFlowContextValue,
|
||||
flushStoredServerAddonInstallQueue,
|
||||
getStoredServerAddonInstallQueue,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
type PendingServerContentInstall,
|
||||
type PendingServerContentInstallType,
|
||||
readPendingServerContentInstalls,
|
||||
readStoredServerInstallQueue,
|
||||
removePendingServerContentInstall,
|
||||
writePendingServerContentInstallBaseline,
|
||||
writeStoredServerInstallQueue,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
type ServerFlowFrom = 'onboarding' | 'reset-server'
|
||||
|
||||
type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
|
||||
title?: string
|
||||
installing?: boolean
|
||||
installed?: boolean
|
||||
}
|
||||
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
|
||||
|
||||
export interface ServerModpackSelectionRequest {
|
||||
projectId: string
|
||||
versionId: string
|
||||
name: string
|
||||
iconUrl?: string
|
||||
}
|
||||
|
||||
interface ServerSetupModalHandle {
|
||||
show: () => void | Promise<void>
|
||||
hide: () => void
|
||||
ctx?: CreationFlowContextValue | null
|
||||
}
|
||||
|
||||
export interface ServerInstallContentContext {
|
||||
serverIdQuery: ComputedRef<string | null>
|
||||
worldIdQuery: ComputedRef<string | null>
|
||||
browseFrom: ComputedRef<string | null>
|
||||
serverFlowFrom: ComputedRef<ServerFlowFrom | null>
|
||||
isFromWorlds: ComputedRef<boolean>
|
||||
isServerContext: ComputedRef<boolean>
|
||||
isSetupServerContext: ComputedRef<boolean>
|
||||
effectiveServerWorldId: ComputedRef<string | null>
|
||||
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
|
||||
serverContentProjectIds: Ref<Set<string>>
|
||||
queuedServerInstallProjectIds: ComputedRef<Set<string>>
|
||||
queuedServerInstallCount: ComputedRef<number>
|
||||
selectedServerInstallProjects: ComputedRef<BrowseSelectedProject[]>
|
||||
isInstallingQueuedServerInstalls: Ref<boolean>
|
||||
queuedInstallProgress: Ref<{ completed: number; total: number }>
|
||||
serverBackUrl: ComputedRef<string>
|
||||
serverBackLabel: ComputedRef<string>
|
||||
serverBrowseHeading: ComputedRef<string>
|
||||
clearQueuedServerInstalls: () => void
|
||||
removeQueuedServerInstall: (projectId: string) => void
|
||||
flushQueuedServerInstalls: () => Promise<boolean>
|
||||
discardQueuedServerInstallsAndBack: () => Promise<void>
|
||||
installQueuedServerInstallsAndBack: () => Promise<boolean>
|
||||
initServerContext: () => Promise<void>
|
||||
watchServerContextChanges: () => void
|
||||
searchServerModpacks: (
|
||||
query: string,
|
||||
limit?: number,
|
||||
) => Promise<Labrinth.Projects.v2.SearchResult>
|
||||
getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]>
|
||||
enforceSetupModpackRoute: (currentProjectType: string | undefined) => void
|
||||
getQueuedServerInstallPlans: () => Map<string, BrowseInstallPlan<InstallableSearchResult>>
|
||||
setQueuedServerInstallPlans: (
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) => void
|
||||
openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise<void>
|
||||
onServerFlowBack: () => void
|
||||
handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise<void>
|
||||
markServerProjectInstalled: (id: string) => void
|
||||
}
|
||||
|
||||
export const [injectServerInstallContent, provideServerInstallContent] =
|
||||
createContext<ServerInstallContentContext>('Browse', 'serverInstallContent')
|
||||
|
||||
function readQueryString(value: unknown): string | null {
|
||||
if (Array.isArray(value)) return value[0] ?? null
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
|
||||
if (project.organization) {
|
||||
const ownerId = project.organization_id ?? project.organization
|
||||
return {
|
||||
id: ownerId,
|
||||
name: project.organization,
|
||||
type: 'organization' as const,
|
||||
link: `https://modrinth.com/organization/${ownerId}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!project.author) return null
|
||||
|
||||
const ownerId = project.author_id ?? project.author
|
||||
return {
|
||||
id: ownerId,
|
||||
name: project.author,
|
||||
type: 'user' as const,
|
||||
link: `https://modrinth.com/user/${ownerId}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function getQueuedInstallOwner(
|
||||
client: AbstractModrinthClient,
|
||||
project: InstallableSearchResult,
|
||||
) {
|
||||
const fallback = getQueuedInstallOwnerFallback(project)
|
||||
|
||||
try {
|
||||
if (project.organization) {
|
||||
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
|
||||
if (organization) {
|
||||
return {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
type: 'organization' as const,
|
||||
avatar_url: organization.icon_url ?? undefined,
|
||||
link: `https://modrinth.com/organization/${organization.slug}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
|
||||
const owner =
|
||||
members.find((member) => member.user.id === project.author_id)?.user ??
|
||||
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
|
||||
members[0]?.user
|
||||
|
||||
if (owner) {
|
||||
return {
|
||||
id: owner.id,
|
||||
name: owner.username,
|
||||
type: 'user' as const,
|
||||
avatar_url: owner.avatar_url,
|
||||
link: `https://modrinth.com/user/${owner.username}`,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function getQueuedAddonInstallPlans(
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
|
||||
}
|
||||
|
||||
function getQueuedInstallPlaceholder(
|
||||
plan: BrowseInstallPlan<InstallableSearchResult>,
|
||||
owner: PendingServerContentInstallInput['owner'],
|
||||
): PendingServerContentInstallInput {
|
||||
const project = plan.project as InstallableSearchResult & { slug?: string | null }
|
||||
return {
|
||||
projectId: plan.projectId,
|
||||
versionId: plan.versionId,
|
||||
contentType: plan.contentType as PendingServerContentInstallType,
|
||||
title: project.title ?? project.name ?? 'Project',
|
||||
versionName: plan.versionName ?? null,
|
||||
versionNumber: plan.versionNumber ?? null,
|
||||
fileName: plan.fileName ?? null,
|
||||
owner,
|
||||
slug: project.slug ?? plan.projectId,
|
||||
iconUrl: project.icon_url ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function getQueuedInstallPlaceholderFallbacks(
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return getQueuedAddonInstallPlans(plans).map((plan) =>
|
||||
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
|
||||
)
|
||||
}
|
||||
|
||||
async function getQueuedInstallPlaceholders(
|
||||
client: AbstractModrinthClient,
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return Promise.all(
|
||||
getQueuedAddonInstallPlans(plans).map(async (plan) =>
|
||||
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function createServerInstallContent(opts: {
|
||||
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
|
||||
}) {
|
||||
const { serverSetupModalRef } = opts
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const client = injectModrinthClient()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const serverIdQuery = computed(() => readQueryString(route.query.sid))
|
||||
const worldIdQuery = computed(() => readQueryString(route.query.wid))
|
||||
const browseFrom = computed(() => readQueryString(route.query.from))
|
||||
const serverFlowFrom = computed<ServerFlowFrom | null>(() =>
|
||||
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
|
||||
? browseFrom.value
|
||||
: null,
|
||||
)
|
||||
|
||||
const isFromWorlds = computed(() => browseFrom.value === 'worlds')
|
||||
const isServerContext = computed(() => !!serverIdQuery.value)
|
||||
const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value)
|
||||
|
||||
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
|
||||
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
|
||||
const serverContentProjectIds = ref<Set<string>>(new Set())
|
||||
const serverContentInstallKeys = ref<Set<string>>(new Set())
|
||||
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
|
||||
new Map(),
|
||||
)
|
||||
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
|
||||
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
|
||||
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
|
||||
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
|
||||
id: plan.projectId,
|
||||
name: plan.project.title ?? plan.project.name ?? 'Project',
|
||||
iconUrl: plan.project.icon_url ?? null,
|
||||
})),
|
||||
)
|
||||
const isInstallingQueuedServerInstalls = ref(false)
|
||||
const queuedInstallProgress = ref({ completed: 0, total: 0 })
|
||||
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
|
||||
const serverBackUrl = computed(() => {
|
||||
const sid = serverIdQuery.value
|
||||
if (!sid) return '/hosting/manage'
|
||||
if (serverFlowFrom.value === 'onboarding') {
|
||||
return `/hosting/manage/${sid}?resumeModal=setup-type`
|
||||
}
|
||||
if (serverFlowFrom.value === 'reset-server') {
|
||||
return `/hosting/manage/${sid}?openSettings=installation`
|
||||
}
|
||||
return `/hosting/manage/${sid}/content`
|
||||
})
|
||||
const serverBackLabel = computed(() => {
|
||||
if (serverFlowFrom.value === 'onboarding') return 'Back to setup'
|
||||
if (serverFlowFrom.value === 'reset-server') return 'Cancel reset'
|
||||
return 'Back to server'
|
||||
})
|
||||
const serverBrowseHeading = computed(() => {
|
||||
if (serverFlowFrom.value === 'reset-server') {
|
||||
return 'Selecting modpack to install after reset'
|
||||
}
|
||||
return 'Installing content'
|
||||
})
|
||||
|
||||
async function resolveServerContextWorldId(serverId: string) {
|
||||
try {
|
||||
const server = await client.archon.servers_v1.get(serverId)
|
||||
const activeWorld = server.worlds.find((world) => world.is_active)
|
||||
return activeWorld?.id ?? server.worlds[0]?.id ?? null
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshServerInstalledContent(serverId: string, worldId: string) {
|
||||
try {
|
||||
const content = await client.archon.content_v1.getAddons(serverId, worldId)
|
||||
const ids = new Set(
|
||||
(content.addons ?? [])
|
||||
.map((addon) => addon.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
)
|
||||
const keys = new Set(
|
||||
(content.addons ?? []).map((addon) => addon.project_id ?? addon.filename),
|
||||
)
|
||||
serverContentProjectIds.value = ids
|
||||
serverContentInstallKeys.value = keys
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
async function initServerContext() {
|
||||
const sid = serverIdQuery.value
|
||||
if (!sid) return
|
||||
|
||||
try {
|
||||
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
|
||||
let resolvedWorldId = effectiveServerWorldId.value
|
||||
if (!resolvedWorldId) {
|
||||
resolvedWorldId = await resolveServerContextWorldId(sid)
|
||||
if (resolvedWorldId) {
|
||||
serverContextWorldId.value = resolvedWorldId
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedWorldId) {
|
||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, resolvedWorldId)
|
||||
await refreshServerInstalledContent(sid, resolvedWorldId)
|
||||
}
|
||||
}
|
||||
|
||||
function watchServerContextChanges() {
|
||||
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => {
|
||||
if (!sid) {
|
||||
serverContextServerData.value = null
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
setQueuedServerInstallPlans(new Map())
|
||||
return
|
||||
}
|
||||
|
||||
if (sid !== prevSid) {
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||
try {
|
||||
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
if (wid !== prevWid) {
|
||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||
}
|
||||
|
||||
if (wid && (sid !== prevSid || wid !== prevWid)) {
|
||||
await refreshServerInstalledContent(sid, wid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function enforceSetupModpackRoute(currentProjectType: string | undefined) {
|
||||
if (!isSetupServerContext.value || currentProjectType === 'modpack') return
|
||||
router.replace({
|
||||
path: '/browse/modpack',
|
||||
query: route.query,
|
||||
})
|
||||
}
|
||||
|
||||
async function searchServerModpacks(query: string, limit: number = 10) {
|
||||
return client.labrinth.projects_v2.search({
|
||||
query: query || undefined,
|
||||
new_filters:
|
||||
'project_types = "modpack" AND (client_side = "optional" OR client_side = "required") AND server_side = "required"',
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
async function getServerProjectVersions(projectId: string) {
|
||||
const versions = await client.labrinth.versions_v3.getProjectVersions(projectId)
|
||||
return versions.map((version) => ({ id: version.id }))
|
||||
}
|
||||
|
||||
async function openServerModpackInstallFlow(request: ServerModpackSelectionRequest) {
|
||||
if (!serverIdQuery.value || !effectiveServerWorldId.value) {
|
||||
throw new Error('Missing server context')
|
||||
}
|
||||
|
||||
const modalInstance = serverSetupModalRef.value
|
||||
if (!modalInstance) return
|
||||
|
||||
modalInstance.show()
|
||||
await nextTick()
|
||||
|
||||
const ctx = modalInstance.ctx
|
||||
if (!ctx) return
|
||||
|
||||
ctx.setupType.value = 'modpack'
|
||||
ctx.modpackSelection.value = {
|
||||
projectId: request.projectId,
|
||||
versionId: request.versionId,
|
||||
name: request.name,
|
||||
iconUrl: request.iconUrl,
|
||||
}
|
||||
ctx.modal.value?.setStage('final-config')
|
||||
}
|
||||
|
||||
function clearQueuedServerInstalls() {
|
||||
setQueuedServerInstallPlans(new Map())
|
||||
}
|
||||
|
||||
function removeQueuedServerInstall(projectId: string) {
|
||||
const nextPlans = new Map(queuedServerInstalls.value)
|
||||
nextPlans.delete(projectId)
|
||||
setQueuedServerInstallPlans(nextPlans)
|
||||
}
|
||||
|
||||
function setStoredServerInstallPlans(
|
||||
serverId: string,
|
||||
worldId: string,
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
if (serverId === serverIdQuery.value && worldId === effectiveServerWorldId.value) {
|
||||
queuedServerInstalls.value = plans
|
||||
}
|
||||
writeStoredServerInstallQueue(serverId, worldId, plans)
|
||||
}
|
||||
|
||||
async function flushQueuedServerInstalls(
|
||||
serverId: string | null = serverIdQuery.value,
|
||||
worldId: string | null = effectiveServerWorldId.value,
|
||||
) {
|
||||
if (isInstallingQueuedServerInstalls.value) return false
|
||||
|
||||
if (!serverId || !worldId) {
|
||||
handleError(new Error('No server world is available for install.'))
|
||||
return false
|
||||
}
|
||||
|
||||
const queuedPlans = getStoredServerAddonInstallQueue<InstallableSearchResult>(serverId, worldId)
|
||||
if (queuedPlans.size === 0) return true
|
||||
|
||||
isInstallingQueuedServerInstalls.value = true
|
||||
queuedInstallProgress.value = {
|
||||
completed: 0,
|
||||
total: queuedPlans.size,
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await flushStoredServerAddonInstallQueue({
|
||||
serverId,
|
||||
worldId,
|
||||
install: (plans) =>
|
||||
client.archon.content_v1.addAddons(
|
||||
serverId,
|
||||
worldId,
|
||||
plans.map((plan) => ({
|
||||
project_id: plan.projectId,
|
||||
version_id: plan.versionId,
|
||||
})),
|
||||
),
|
||||
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
for (const plan of result.attemptedPlans) {
|
||||
removePendingServerContentInstall(serverId, worldId, plan.projectId)
|
||||
}
|
||||
handleError(result.error as Error)
|
||||
return false
|
||||
}
|
||||
|
||||
queuedInstallProgress.value = {
|
||||
completed: result.flushedPlans.length,
|
||||
total: result.flushedPlans.length,
|
||||
}
|
||||
serverContentProjectIds.value = new Set([
|
||||
...serverContentProjectIds.value,
|
||||
...result.flushedPlans.map((plan) => plan.projectId),
|
||||
])
|
||||
serverContentInstallKeys.value = new Set([
|
||||
...serverContentInstallKeys.value,
|
||||
...result.flushedPlans.map((plan) => plan.projectId),
|
||||
])
|
||||
if (result.flushedPlans.length > 0) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] })
|
||||
}
|
||||
|
||||
return true
|
||||
} finally {
|
||||
isInstallingQueuedServerInstalls.value = false
|
||||
queuedInstallProgress.value = { completed: 0, total: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
async function discardQueuedServerInstallsAndBack() {
|
||||
clearQueuedServerInstalls()
|
||||
await router.push(serverBackUrl.value)
|
||||
}
|
||||
|
||||
async function installQueuedServerInstallsAndBack() {
|
||||
const sid = serverIdQuery.value
|
||||
const wid = effectiveServerWorldId.value
|
||||
const backUrl = serverBackUrl.value
|
||||
const plans = new Map(queuedServerInstalls.value)
|
||||
|
||||
if (sid && wid) {
|
||||
writeStoredServerInstallQueue(sid, wid, plans)
|
||||
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
|
||||
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
|
||||
void getQueuedInstallPlaceholders(client, plans)
|
||||
.then((items) => {
|
||||
const pendingProjectIds = new Set(
|
||||
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
|
||||
)
|
||||
addPendingServerContentInstalls(
|
||||
sid,
|
||||
wid,
|
||||
items.filter((item) => pendingProjectIds.has(item.projectId)),
|
||||
)
|
||||
})
|
||||
.catch((err) => handleError(err as Error))
|
||||
}
|
||||
await router.push(backUrl)
|
||||
void flushQueuedServerInstalls(sid, wid)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getQueuedServerInstallPlans() {
|
||||
return queuedServerInstalls.value
|
||||
}
|
||||
|
||||
function setQueuedServerInstallPlans(
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
queuedServerInstalls.value = plans
|
||||
writeStoredServerInstallQueue(serverIdQuery.value, effectiveServerWorldId.value, plans)
|
||||
}
|
||||
|
||||
function onServerFlowBack() {
|
||||
serverSetupModalRef.value?.hide()
|
||||
}
|
||||
|
||||
async function handleServerModpackFlowCreate(config: CreationFlowContextValue) {
|
||||
const sid = serverIdQuery.value
|
||||
const wid = effectiveServerWorldId.value
|
||||
if (!sid || !wid || !config.modpackSelection.value) {
|
||||
config.loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await client.archon.content_v1.installContent(sid, wid, {
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
platform: 'modrinth',
|
||||
project_id: config.modpackSelection.value.projectId,
|
||||
version_id: config.modpackSelection.value.versionId,
|
||||
},
|
||||
soft_override: false,
|
||||
properties: config.buildProperties(),
|
||||
} satisfies Archon.Content.v1.InstallWorldContent)
|
||||
serverSetupModalRef.value?.hide()
|
||||
|
||||
if (serverFlowFrom.value === 'onboarding') {
|
||||
await client.archon.servers_v1.endIntro(sid)
|
||||
await router.push(`/hosting/manage/${sid}/content`)
|
||||
return
|
||||
}
|
||||
|
||||
await router.push(`/hosting/manage/${sid}?openSettings=installation`)
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
config.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function markServerProjectInstalled(id: string) {
|
||||
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id])
|
||||
}
|
||||
|
||||
return {
|
||||
serverIdQuery,
|
||||
worldIdQuery,
|
||||
browseFrom,
|
||||
serverFlowFrom,
|
||||
isFromWorlds,
|
||||
isServerContext,
|
||||
isSetupServerContext,
|
||||
effectiveServerWorldId,
|
||||
serverContextServerData,
|
||||
serverContentProjectIds,
|
||||
queuedServerInstallProjectIds,
|
||||
queuedServerInstallCount,
|
||||
selectedServerInstallProjects,
|
||||
isInstallingQueuedServerInstalls,
|
||||
queuedInstallProgress,
|
||||
serverBackUrl,
|
||||
serverBackLabel,
|
||||
serverBrowseHeading,
|
||||
clearQueuedServerInstalls,
|
||||
removeQueuedServerInstall,
|
||||
flushQueuedServerInstalls,
|
||||
discardQueuedServerInstallsAndBack,
|
||||
installQueuedServerInstallsAndBack,
|
||||
initServerContext,
|
||||
watchServerContextChanges,
|
||||
searchServerModpacks,
|
||||
getServerProjectVersions,
|
||||
enforceSetupModpackRoute,
|
||||
getQueuedServerInstallPlans,
|
||||
setQueuedServerInstallPlans,
|
||||
openServerModpackInstallFlow,
|
||||
onServerFlowBack,
|
||||
handleServerModpackFlowCreate,
|
||||
markServerProjectInstalled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AbstractWebNotificationManager } from '@modrinth/ui'
|
||||
import { provideTags } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
|
||||
export function setupTagsProvider(
|
||||
notificationManager: AbstractWebNotificationManager,
|
||||
stateInitialization: Promise<void>,
|
||||
) {
|
||||
const { handleError } = notificationManager
|
||||
|
||||
const gameVersions = ref([])
|
||||
const loaders = ref([])
|
||||
stateInitialization
|
||||
.then(() => {
|
||||
get_game_versions()
|
||||
.then((v) => {
|
||||
gameVersions.value = v
|
||||
})
|
||||
.catch(handleError)
|
||||
get_loaders()
|
||||
.then((v) => {
|
||||
loaders.value = v
|
||||
})
|
||||
.catch(handleError)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
provideTags({ gameVersions, loaders })
|
||||
}
|
||||
Reference in new issue
Block a user