feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
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 })
}