forked from AxTps/Starlight_Lancher
重构:移除多人服务器组件及相关逻辑
This commit is contained in:
@ -2464,7 +2464,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
class="app-contents"
|
class="app-contents"
|
||||||
:class="{
|
:class="{
|
||||||
'sidebar-enabled': sidebarVisible,
|
'sidebar-enabled': sidebarVisible,
|
||||||
'studio-mode': route.name === 'FileStudio' || route.name === 'MultiplayerServerFileStudio',
|
'studio-mode': route.name === 'FileStudio',
|
||||||
'disable-advanced-rendering': !themeStore.advancedRendering,
|
'disable-advanced-rendering': !themeStore.advancedRendering,
|
||||||
'has-custom-background': themeStore.customBackgroundPath && !themeStore.transparentBackground,
|
'has-custom-background': themeStore.customBackgroundPath && !themeStore.transparentBackground,
|
||||||
'has-transparent-background': themeStore.transparentBackground,
|
'has-transparent-background': themeStore.transparentBackground,
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { commonMessages, defineMessages, MultiStageModal } from '@modrinth/ui'
|
|
||||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
|
||||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
||||||
|
|
||||||
import {
|
|
||||||
createCreateServerFlowContext,
|
|
||||||
provideCreateServerFlow,
|
|
||||||
} from '@/components/multiplayer/servers/create-server-flow'
|
|
||||||
import EulaModal from '@/components/multiplayer/servers/EulaModal.vue'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
created: [serverId: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
|
|
||||||
const eulaModal = useTemplateRef<ComponentExposed<typeof EulaModal>>('eulaModal')
|
|
||||||
|
|
||||||
const ctx = createCreateServerFlowContext(modal)
|
|
||||||
provideCreateServerFlow(ctx)
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
downloadInBackground: {
|
|
||||||
id: 'app.servers.wizard.download-in-background',
|
|
||||||
defaultMessage: 'Download in background',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const wizardShown = ref(false)
|
|
||||||
const wasHiddenDuringInstall = ref(false)
|
|
||||||
const creationReported = ref(false)
|
|
||||||
|
|
||||||
const cancelButton = computed(() => {
|
|
||||||
if (ctx.installPhase.value === 'done') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// The download continues in the background once the wizard closes; only the
|
|
||||||
// first-run boot locks closing until the server reaches its EULA gate.
|
|
||||||
return {
|
|
||||||
label: ctx.formatMessage(
|
|
||||||
ctx.installPhase.value === 'downloading'
|
|
||||||
? messages.downloadInBackground
|
|
||||||
: commonMessages.cancelButton,
|
|
||||||
),
|
|
||||||
disabled: ctx.installPhase.value === 'first-run',
|
|
||||||
onClick: () => modal.value?.hide(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(ctx.showEulaModal, (visible) => {
|
|
||||||
if (visible) {
|
|
||||||
// When the setup finished in the background, don't pop a EULA dialog over
|
|
||||||
// whatever page the user is on; starting the server gates on it instead.
|
|
||||||
if (wizardShown.value) eulaModal.value?.show()
|
|
||||||
} else {
|
|
||||||
eulaModal.value?.hide()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function show() {
|
|
||||||
wizardShown.value = true
|
|
||||||
wasHiddenDuringInstall.value = false
|
|
||||||
creationReported.value = false
|
|
||||||
ctx.reset()
|
|
||||||
modal.value?.setStage(0)
|
|
||||||
modal.value?.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHide() {
|
|
||||||
const wasShown = wizardShown.value
|
|
||||||
wizardShown.value = false
|
|
||||||
// An explicit "Finish" (wizard still open at a terminal state) navigates to
|
|
||||||
// the new server. A background close (wizard dismissed mid-install) leaves
|
|
||||||
// the server in the list instead of yanking the user to another page.
|
|
||||||
if (
|
|
||||||
wasShown &&
|
|
||||||
!wasHiddenDuringInstall.value &&
|
|
||||||
ctx.createdServer.value &&
|
|
||||||
(ctx.installPhase.value === 'done' || ctx.installPhase.value === 'eula')
|
|
||||||
) {
|
|
||||||
if (!creationReported.value) {
|
|
||||||
creationReported.value = true
|
|
||||||
emit('created', ctx.createdServer.value.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wasHiddenDuringInstall.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ show, hide: () => modal.value?.hide() })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<MultiStageModal
|
|
||||||
ref="modal"
|
|
||||||
:stages="ctx.stageConfigs"
|
|
||||||
:context="ctx"
|
|
||||||
:back-button-enabled="
|
|
||||||
(flowCtx) =>
|
|
||||||
flowCtx.installPhase.value !== 'downloading' && flowCtx.installPhase.value !== 'first-run'
|
|
||||||
"
|
|
||||||
:cancel-button="cancelButton"
|
|
||||||
@hide="handleHide"
|
|
||||||
/>
|
|
||||||
<EulaModal
|
|
||||||
ref="eulaModal"
|
|
||||||
:text="ctx.eulaText.value"
|
|
||||||
@continue="ctx.acceptEula"
|
|
||||||
@decline="ctx.declineEula"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
|
||||||
import { ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
|
||||||
import { useTemplateRef } from 'vue'
|
|
||||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
continue: []
|
|
||||||
decline: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: { id: 'app.servers.eula.title', defaultMessage: 'Minecraft EULA' },
|
|
||||||
description: {
|
|
||||||
id: 'app.servers.eula.description',
|
|
||||||
defaultMessage:
|
|
||||||
'By continuing, you agree to the Minecraft End User License Agreement (EULA). Please review the agreement below before proceeding.',
|
|
||||||
},
|
|
||||||
continue: {
|
|
||||||
id: 'app.servers.eula.continue',
|
|
||||||
defaultMessage: 'Continue',
|
|
||||||
},
|
|
||||||
decline: { id: 'app.servers.eula.decline', defaultMessage: 'Cancel' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const modal = useTemplateRef<ComponentExposed<typeof NewModal>>('modal')
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show: (event?: MouseEvent) => modal.value?.show(event),
|
|
||||||
hide: () => modal.value?.hide(),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NewModal ref="modal" :header="formatMessage(messages.title)">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<p class="m-0 text-secondary">
|
|
||||||
{{ formatMessage(messages.description) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<template #actions>
|
|
||||||
<div class="flex flex-col justify-end gap-2 sm:flex-row">
|
|
||||||
<ButtonStyled type="outlined">
|
|
||||||
<button type="button" @click="emit('decline')">
|
|
||||||
<XIcon />
|
|
||||||
{{ formatMessage(messages.decline) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button type="button" @click="emit('continue')">
|
|
||||||
<CheckIcon />
|
|
||||||
{{ formatMessage(messages.continue) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NewModal>
|
|
||||||
</template>
|
|
||||||
@ -1,263 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
DownloadIcon,
|
|
||||||
PlayIcon,
|
|
||||||
RefreshCwIcon,
|
|
||||||
SpinnerIcon,
|
|
||||||
StopCircleIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import { ButtonStyled, defineMessages, TagItem, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import {
|
|
||||||
isServerStatusVisible,
|
|
||||||
SERVER_STATUS_META,
|
|
||||||
} from '@/components/multiplayer/servers/server-status'
|
|
||||||
import ServerIcon from '@/components/multiplayer/servers/ServerIcon.vue'
|
|
||||||
import { serverSetupStatus } from '@/composables/useServerInstalls'
|
|
||||||
import type { ServerView } from '@/composables/useServers'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
server: ServerView
|
|
||||||
variant: 'standard' | 'library'
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
open: []
|
|
||||||
'start-stop': []
|
|
||||||
resume: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const messages = defineMessages({
|
|
||||||
start: { id: 'app.servers.action.start', defaultMessage: 'Start' },
|
|
||||||
stop: { id: 'app.servers.action.stop', defaultMessage: 'Stop' },
|
|
||||||
continueDownload: {
|
|
||||||
id: 'app.servers.action.continue-download',
|
|
||||||
defaultMessage: 'Continue download',
|
|
||||||
},
|
|
||||||
retryDownload: { id: 'app.servers.action.retry-download', defaultMessage: 'Retry download' },
|
|
||||||
downloading: { id: 'app.servers.status.downloading', defaultMessage: 'Downloading' },
|
|
||||||
downloadInterrupted: {
|
|
||||||
id: 'app.servers.status.download-interrupted',
|
|
||||||
defaultMessage: 'Download interrupted',
|
|
||||||
},
|
|
||||||
downloadFailed: { id: 'app.servers.status.download-failed', defaultMessage: 'Download failed' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusMeta = computed(() => SERVER_STATUS_META[props.server.status])
|
|
||||||
|
|
||||||
const setupStatus = computed(() => serverSetupStatus(props.server))
|
|
||||||
|
|
||||||
/** Setup states take precedence over the runtime status tag. */
|
|
||||||
const displayTag = computed(() => {
|
|
||||||
switch (setupStatus.value) {
|
|
||||||
case 'installing':
|
|
||||||
return { label: messages.downloading, color: 'text-orange' }
|
|
||||||
case 'interrupted':
|
|
||||||
return { label: messages.downloadInterrupted, color: 'text-orange' }
|
|
||||||
case 'failed':
|
|
||||||
return { label: messages.downloadFailed, color: 'text-red' }
|
|
||||||
default:
|
|
||||||
return isServerStatusVisible(props.server.status)
|
|
||||||
? { label: statusMeta.value.label, color: statusMeta.value.color }
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const setupTooltip = computed(() => {
|
|
||||||
if (setupStatus.value === 'interrupted') return formatMessage(messages.continueDownload)
|
|
||||||
if (setupStatus.value === 'failed') return formatMessage(messages.retryDownload)
|
|
||||||
return formatMessage(messages.downloading)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="variant === 'library'"
|
|
||||||
data-onboarding-id="server-card"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
class="group relative flex w-full cursor-pointer select-none flex-col items-start justify-end gap-3 overflow-clip rounded-[20px] border border-solid border-surface-4 bg-surface-3 p-3 text-left transition-[border-color,filter,transform] hover:border-surface-5 hover:brightness-110 active:scale-[0.98]"
|
|
||||||
@click="emit('open')"
|
|
||||||
@keydown.enter="emit('open')"
|
|
||||||
@keydown.space.prevent="emit('open')"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative flex aspect-square w-full shrink-0 items-center justify-center overflow-clip rounded-2xl bg-surface-2"
|
|
||||||
>
|
|
||||||
<ServerIcon
|
|
||||||
:icon-path="server.iconPath"
|
|
||||||
:server-type="server.serverType"
|
|
||||||
:server-id="server.id"
|
|
||||||
size="96px"
|
|
||||||
/>
|
|
||||||
<TagItem v-if="displayTag" class="absolute left-3 top-3">
|
|
||||||
<span :class="'font-semibold ' + displayTag.color">
|
|
||||||
{{ formatMessage(displayTag.label) }}
|
|
||||||
</span>
|
|
||||||
</TagItem>
|
|
||||||
<div class="absolute bottom-1.5 right-1.5" @click.stop @keydown.stop>
|
|
||||||
<div
|
|
||||||
v-if="setupStatus === 'installing'"
|
|
||||||
v-tooltip="setupTooltip"
|
|
||||||
class="flex size-10 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-3"
|
|
||||||
>
|
|
||||||
<SpinnerIcon class="size-5 animate-spin text-orange" />
|
|
||||||
</div>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else-if="setupStatus === 'interrupted'"
|
|
||||||
v-tooltip="setupTooltip"
|
|
||||||
color="brand"
|
|
||||||
size="large"
|
|
||||||
circular
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
|
||||||
@click="emit('resume')"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else-if="setupStatus === 'failed'"
|
|
||||||
v-tooltip="setupTooltip"
|
|
||||||
color="red"
|
|
||||||
size="large"
|
|
||||||
circular
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
|
||||||
@click="emit('resume')"
|
|
||||||
>
|
|
||||||
<RefreshCwIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else-if="server.status !== 'running'" color="brand" size="large" circular>
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(messages.start)"
|
|
||||||
type="button"
|
|
||||||
class="scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
|
||||||
@click="emit('start-stop')"
|
|
||||||
>
|
|
||||||
<PlayIcon class="translate-x-[1px]" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else color="red" size="large" circular>
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(messages.stop)"
|
|
||||||
type="button"
|
|
||||||
class="scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
|
||||||
@click="emit('start-stop')"
|
|
||||||
>
|
|
||||||
<StopCircleIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex w-full min-w-0 flex-col items-start justify-center gap-1 px-0.5">
|
|
||||||
<p class="m-0 w-full truncate text-base font-semibold leading-5 text-contrast">
|
|
||||||
{{ server.name }}
|
|
||||||
</p>
|
|
||||||
<p class="m-0 w-full truncate text-sm font-medium leading-[18px] text-primary">
|
|
||||||
{{ server.serverType }} {{ server.gameVersion }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
data-onboarding-id="server-card"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
class="group button-base flex w-full cursor-pointer select-none gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-4 text-left transition-[border-color,filter,transform] hover:border-surface-5 hover:brightness-110 active:scale-[0.98]"
|
|
||||||
@click="emit('open')"
|
|
||||||
@keydown.enter="emit('open')"
|
|
||||||
@keydown.space.prevent="emit('open')"
|
|
||||||
>
|
|
||||||
<div class="relative flex size-12 shrink-0 items-center justify-center">
|
|
||||||
<ServerIcon
|
|
||||||
:icon-path="server.iconPath"
|
|
||||||
:server-type="server.serverType"
|
|
||||||
:server-id="server.id"
|
|
||||||
size="48px"
|
|
||||||
class="transition-all group-hover:brightness-75"
|
|
||||||
/>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center" @click.stop @keydown.stop>
|
|
||||||
<div
|
|
||||||
v-if="setupStatus === 'installing'"
|
|
||||||
v-tooltip="setupTooltip"
|
|
||||||
class="flex size-9 origin-bottom scale-75 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-3 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
|
||||||
>
|
|
||||||
<SpinnerIcon class="size-4 animate-spin text-orange" />
|
|
||||||
</div>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else-if="setupStatus === 'interrupted'"
|
|
||||||
v-tooltip="setupTooltip"
|
|
||||||
color="brand"
|
|
||||||
size="large"
|
|
||||||
circular
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="origin-bottom scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
|
||||||
@click="emit('resume')"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else-if="setupStatus === 'failed'"
|
|
||||||
v-tooltip="setupTooltip"
|
|
||||||
color="red"
|
|
||||||
size="large"
|
|
||||||
circular
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="origin-bottom scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
|
||||||
@click="emit('resume')"
|
|
||||||
>
|
|
||||||
<RefreshCwIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else-if="server.status !== 'running'" color="brand" size="large" circular>
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(messages.start)"
|
|
||||||
type="button"
|
|
||||||
class="origin-bottom scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
|
||||||
@click="emit('start-stop')"
|
|
||||||
>
|
|
||||||
<PlayIcon class="translate-x-[1px]" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else color="red" size="large" circular>
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(messages.stop)"
|
|
||||||
type="button"
|
|
||||||
class="origin-bottom scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
|
||||||
@click="emit('start-stop')"
|
|
||||||
>
|
|
||||||
<StopCircleIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
|
||||||
<p class="m-0 min-w-0 truncate text-base font-bold leading-tight text-contrast">
|
|
||||||
{{ server.name }}
|
|
||||||
</p>
|
|
||||||
<TagItem v-if="displayTag" class="shrink-0">
|
|
||||||
<span :class="'font-semibold ' + displayTag.color">
|
|
||||||
{{ formatMessage(displayTag.label) }}
|
|
||||||
</span>
|
|
||||||
</TagItem>
|
|
||||||
</div>
|
|
||||||
<p class="m-0 mt-1 truncate text-sm font-semibold text-secondary">
|
|
||||||
{{ server.serverType }} {{ server.gameVersion }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
ConsolePageLayout,
|
|
||||||
createConsoleState,
|
|
||||||
defineMessages,
|
|
||||||
JLineCommandInput,
|
|
||||||
provideConsoleManager,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
import { ServerConsoleBuffer } from '@/composables/server-console-buffer'
|
|
||||||
import {
|
|
||||||
hydrateLog,
|
|
||||||
type ServerView,
|
|
||||||
subscribeServerConsoleOutput,
|
|
||||||
useServers,
|
|
||||||
} from '@/composables/useServers'
|
|
||||||
import { servers } from '@/helpers/servers'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
server: ServerView
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const messages = defineMessages({
|
|
||||||
forgeCommandPlaceholder: {
|
|
||||||
id: 'app.servers.console.forge-command-placeholder',
|
|
||||||
defaultMessage: 'Send a command - Tab completion supported',
|
|
||||||
},
|
|
||||||
notRunning: {
|
|
||||||
id: 'app.servers.console.not-running',
|
|
||||||
defaultMessage: 'The server is not running',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { logLines, sendCommand } = useServers()
|
|
||||||
const consoleState = createConsoleState()
|
|
||||||
const loading = ref(true)
|
|
||||||
const hasLogs = computed(() => consoleState.output.value.length > 0)
|
|
||||||
const isForge = computed(() => props.server.serverType === 'forge')
|
|
||||||
const jlineInput = ref<InstanceType<typeof JLineCommandInput> | null>(null)
|
|
||||||
let consumedLines = 0
|
|
||||||
// Guards the live length-watcher from double-appending while we rebuild the
|
|
||||||
// console from the buffer during (re)hydration. Without it, the async
|
|
||||||
// hydrate fetch and the streamed `logLines` updates race, dropping or
|
|
||||||
// duplicating the earliest startup lines.
|
|
||||||
let hydrating = false
|
|
||||||
let unsubscribeConsoleOutput: (() => void) | null = null
|
|
||||||
const PENDING_CONSOLE_OUTPUT_CAPACITY = 64 * 1024
|
|
||||||
let pendingConsoleOutput = new ServerConsoleBuffer(PENDING_CONSOLE_OUTPUT_CAPACITY)
|
|
||||||
|
|
||||||
function flushConsoleOutput() {
|
|
||||||
for (const data of pendingConsoleOutput.values()) jlineInput.value?.write(data)
|
|
||||||
pendingConsoleOutput = new ServerConsoleBuffer(PENDING_CONSOLE_OUTPUT_CAPACITY)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.server.id,
|
|
||||||
(serverId) => {
|
|
||||||
unsubscribeConsoleOutput?.()
|
|
||||||
unsubscribeConsoleOutput = subscribeServerConsoleOutput(serverId, (data) => {
|
|
||||||
if (jlineInput.value) {
|
|
||||||
jlineInput.value.write(data)
|
|
||||||
} else {
|
|
||||||
pendingConsoleOutput.push(data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
async function hydrateAndDisplay() {
|
|
||||||
hydrating = true
|
|
||||||
try {
|
|
||||||
await hydrateLog(props.server.id)
|
|
||||||
const buffer = logLines[props.server.id] ?? []
|
|
||||||
if (buffer.length > 0) await consoleState.addLegacyLog(buffer.join('\n'))
|
|
||||||
consumedLines = buffer.length
|
|
||||||
} finally {
|
|
||||||
hydrating = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The per-line `server` events can drop during heavy bursts, so we
|
|
||||||
// periodically reconcile the displayed log against the lossless backend
|
|
||||||
// buffer. This guarantees the console always shows the complete history,
|
|
||||||
// including the server's startup and command responses that arrived in a
|
|
||||||
// single fast burst.
|
|
||||||
let syncTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
function startSync() {
|
|
||||||
stopSync()
|
|
||||||
syncTimer = setInterval(() => {
|
|
||||||
if (hydrating) return
|
|
||||||
if (!props.server.running) {
|
|
||||||
stopSync()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void hydrateLog(props.server.id)
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
function stopSync() {
|
|
||||||
if (syncTimer) {
|
|
||||||
clearInterval(syncTimer)
|
|
||||||
syncTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
flushConsoleOutput()
|
|
||||||
await hydrateAndDisplay()
|
|
||||||
loading.value = false
|
|
||||||
startSync()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopSync()
|
|
||||||
unsubscribeConsoleOutput?.()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => (logLines[props.server.id] ?? []).length,
|
|
||||||
(count) => {
|
|
||||||
if (loading.value || hydrating) return
|
|
||||||
const lines = logLines[props.server.id] ?? []
|
|
||||||
if (count < consumedLines) {
|
|
||||||
consoleState.clear()
|
|
||||||
consumedLines = 0
|
|
||||||
}
|
|
||||||
const fresh = lines.slice(consumedLines)
|
|
||||||
consumedLines = lines.length
|
|
||||||
if (fresh.length === 0) return
|
|
||||||
for (const line of fresh) {
|
|
||||||
void consoleState.addLegacyLog(line)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async function handleSendCommand(command: string) {
|
|
||||||
// The server echoes the command into its own log (e.g. "> time set 0"),
|
|
||||||
// which the console already shows, so we don't echo it a second time here.
|
|
||||||
await sendCommand(props.server.id, command)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starting a server always resets the console to a clean slate and resumes
|
|
||||||
// bottom-following. The displayed `consoleState` is cleared here, but the
|
|
||||||
// shared `logLines` buffer is intentionally preserved: the global listener may
|
|
||||||
// have already streamed the earliest startup lines, and discarding them (or
|
|
||||||
// letting the async hydrate overwrite them) is what made the launch appear to
|
|
||||||
// have "no startup info". We rebuild the view from whatever `logLines` already
|
|
||||||
// holds, then continue following new lines.
|
|
||||||
const consoleLayout = ref<InstanceType<typeof ConsolePageLayout> | null>(null)
|
|
||||||
watch(
|
|
||||||
() => props.server.running,
|
|
||||||
async (running, previousRunning) => {
|
|
||||||
if (!running) {
|
|
||||||
pendingConsoleOutput = new ServerConsoleBuffer(PENDING_CONSOLE_OUTPUT_CAPACITY)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (previousRunning) return
|
|
||||||
await nextTick()
|
|
||||||
flushConsoleOutput()
|
|
||||||
consoleState.clear()
|
|
||||||
consumedLines = 0
|
|
||||||
// Drop the previous run's lines from the shared buffer too; the backend
|
|
||||||
// cleared its own buffer at launch, so without this the old history
|
|
||||||
// would be rehydrated into the fresh console on every restart.
|
|
||||||
logLines[props.server.id] = []
|
|
||||||
await hydrateAndDisplay()
|
|
||||||
consoleLayout.value?.scrollToBottom()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
provideConsoleManager({
|
|
||||||
logLines: consoleState.output,
|
|
||||||
sendCommand: (command: string) => void handleSendCommand(command),
|
|
||||||
showCommandInput: computed(() => props.server.running),
|
|
||||||
disableCommandInput: computed(() => !props.server.running),
|
|
||||||
disableCommandInputTooltip: computed(() => formatMessage(messages.notRunning)),
|
|
||||||
loading,
|
|
||||||
emptyStateType: 'server',
|
|
||||||
onClear: () => {
|
|
||||||
consoleState.clear()
|
|
||||||
consumedLines = 0
|
|
||||||
// Drop the shared frontend buffer too, otherwise the next incoming log
|
|
||||||
// line replays the entire pre-clear history back into the console.
|
|
||||||
logLines[props.server.id] = []
|
|
||||||
void servers.clearLog(props.server.id).catch(() => {})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
data-onboarding-id="server-console"
|
|
||||||
class="flex flex-col pb-3"
|
|
||||||
:class="hasLogs ? 'h-[calc(100dvh-80px)] shrink-0' : 'h-full min-h-[240px]'"
|
|
||||||
>
|
|
||||||
<ConsolePageLayout ref="consoleLayout" :custom-command-input="isForge">
|
|
||||||
<template #command-input="{ disabled }">
|
|
||||||
<JLineCommandInput
|
|
||||||
ref="jlineInput"
|
|
||||||
:disabled="disabled"
|
|
||||||
:placeholder="formatMessage(messages.forgeCommandPlaceholder)"
|
|
||||||
:send-command="handleSendCommand"
|
|
||||||
:send-input="(data) => servers.sendConsoleInput(server.id, data)"
|
|
||||||
:resize-console="(cols, rows) => servers.resizeConsole(server.id, cols, rows)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ConsolePageLayout>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,505 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
ArrowLeftIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
FolderOpenIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
LoaderCircleIcon,
|
|
||||||
MoreVerticalIcon,
|
|
||||||
PencilIcon,
|
|
||||||
PlayIcon,
|
|
||||||
RefreshCwIcon,
|
|
||||||
ShieldIcon,
|
|
||||||
StopCircleIcon,
|
|
||||||
TerminalSquareIcon,
|
|
||||||
WrenchIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
Admonition,
|
|
||||||
ButtonStyled,
|
|
||||||
defineMessages,
|
|
||||||
injectFilePicker,
|
|
||||||
injectNotificationManager,
|
|
||||||
NavTabs,
|
|
||||||
OverflowMenu,
|
|
||||||
TagItem,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import EulaModal from '@/components/multiplayer/servers/EulaModal.vue'
|
|
||||||
import {
|
|
||||||
isServerStatusVisible,
|
|
||||||
SERVER_STATUS_META,
|
|
||||||
} from '@/components/multiplayer/servers/server-status'
|
|
||||||
import ServerConsole from '@/components/multiplayer/servers/ServerConsole.vue'
|
|
||||||
import ServerFilesPanel from '@/components/multiplayer/servers/ServerFilesPanel.vue'
|
|
||||||
import ServerIcon from '@/components/multiplayer/servers/ServerIcon.vue'
|
|
||||||
import ServerSettingsPanel from '@/components/multiplayer/servers/ServerSettingsPanel.vue'
|
|
||||||
import { useMultiplayerSession } from '@/composables/useMultiplayerSession'
|
|
||||||
import { serverSetupStatus } from '@/composables/useServerInstalls'
|
|
||||||
import { useServerLifecycle } from '@/composables/useServerLifecycle'
|
|
||||||
import { useServers } from '@/composables/useServers'
|
|
||||||
import { type PortProcessInfoData, servers as serversApi } from '@/helpers/servers'
|
|
||||||
import { openPath } from '@/helpers/utils'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const serverId = route.params.id as string
|
|
||||||
|
|
||||||
const { servers, refresh, stopServer } = useServers()
|
|
||||||
const { eulaModal, eulaText, tryStartServer, acceptEula, declineEula, resumeInstall } =
|
|
||||||
useServerLifecycle()
|
|
||||||
const filePicker = injectFilePicker()
|
|
||||||
const multiplayerSession = useMultiplayerSession()
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
console: { id: 'app.servers.detail.console', defaultMessage: 'Console' },
|
|
||||||
files: { id: 'app.servers.detail.files', defaultMessage: 'Files' },
|
|
||||||
settings: { id: 'app.servers.detail.settings', defaultMessage: 'Settings' },
|
|
||||||
back: { id: 'app.servers.detail.back', defaultMessage: 'Servers' },
|
|
||||||
start: { id: 'app.servers.action.start', defaultMessage: 'Start' },
|
|
||||||
stop: { id: 'app.servers.action.stop', defaultMessage: 'Stop' },
|
|
||||||
continueDownload: {
|
|
||||||
id: 'app.servers.action.continue-download',
|
|
||||||
defaultMessage: 'Continue download',
|
|
||||||
},
|
|
||||||
retryDownload: { id: 'app.servers.action.retry-download', defaultMessage: 'Retry download' },
|
|
||||||
downloading: { id: 'app.servers.status.downloading', defaultMessage: 'Downloading' },
|
|
||||||
downloadInterrupted: {
|
|
||||||
id: 'app.servers.status.download-interrupted',
|
|
||||||
defaultMessage: 'Download interrupted',
|
|
||||||
},
|
|
||||||
downloadFailed: { id: 'app.servers.status.download-failed', defaultMessage: 'Download failed' },
|
|
||||||
openFolder: { id: 'app.servers.action.open-folder', defaultMessage: 'Open folder' },
|
|
||||||
share: { id: 'app.servers.action.share', defaultMessage: 'Share online' },
|
|
||||||
notFound: {
|
|
||||||
id: 'app.servers.detail.not-found',
|
|
||||||
defaultMessage: 'This server no longer exists.',
|
|
||||||
},
|
|
||||||
typeLabel: {
|
|
||||||
id: 'app.servers.card.type',
|
|
||||||
defaultMessage: '{type} · {version}',
|
|
||||||
},
|
|
||||||
port: { id: 'app.servers.card.port', defaultMessage: 'Port {port}' },
|
|
||||||
editIcon: { id: 'app.servers.icon.edit', defaultMessage: 'Edit icon' },
|
|
||||||
removeIcon: { id: 'app.servers.icon.remove', defaultMessage: 'Remove icon' },
|
|
||||||
portConflictTitle: {
|
|
||||||
id: 'app.servers.port.conflict-title',
|
|
||||||
defaultMessage: 'Port {port} is already in use',
|
|
||||||
},
|
|
||||||
portConflictDescription: {
|
|
||||||
id: 'app.servers.port.conflict-description',
|
|
||||||
defaultMessage:
|
|
||||||
'{process} is currently occupying this port, so the server cannot start. Change the server port, or force quit the process below.',
|
|
||||||
},
|
|
||||||
portUnknownProcess: {
|
|
||||||
id: 'app.servers.port.unknown-process',
|
|
||||||
defaultMessage: 'Unknown process (PID {pid})',
|
|
||||||
},
|
|
||||||
portForceQuit: {
|
|
||||||
id: 'app.servers.port.force-quit',
|
|
||||||
defaultMessage: 'Force quit process',
|
|
||||||
},
|
|
||||||
portChange: {
|
|
||||||
id: 'app.servers.port.change',
|
|
||||||
defaultMessage: 'Change port',
|
|
||||||
},
|
|
||||||
portRecheck: {
|
|
||||||
id: 'app.servers.port.recheck',
|
|
||||||
defaultMessage: 'Recheck',
|
|
||||||
},
|
|
||||||
portForceQuitFailed: {
|
|
||||||
id: 'app.servers.port.force-quit-failed',
|
|
||||||
defaultMessage: 'Failed to quit the process occupying the port',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = computed(() => servers.value.find((entry) => entry.id === serverId))
|
|
||||||
const statusMeta = computed(() => (server.value ? SERVER_STATUS_META[server.value.status] : null))
|
|
||||||
const showStatus = computed(() =>
|
|
||||||
server.value ? isServerStatusVisible(server.value.status) : false,
|
|
||||||
)
|
|
||||||
|
|
||||||
const setupStatus = computed(() => (server.value ? serverSetupStatus(server.value) : null))
|
|
||||||
|
|
||||||
/** Setup states take precedence over the runtime status tag. */
|
|
||||||
const displayTag = computed(() => {
|
|
||||||
switch (setupStatus.value) {
|
|
||||||
case 'installing':
|
|
||||||
return { label: messages.downloading, color: 'text-orange' }
|
|
||||||
case 'interrupted':
|
|
||||||
return { label: messages.downloadInterrupted, color: 'text-orange' }
|
|
||||||
case 'failed':
|
|
||||||
return { label: messages.downloadFailed, color: 'text-red' }
|
|
||||||
default:
|
|
||||||
return showStatus.value && statusMeta.value
|
|
||||||
? { label: statusMeta.value.label, color: statusMeta.value.color }
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLoaded = ref(false)
|
|
||||||
const hasSeenServer = ref(false)
|
|
||||||
|
|
||||||
const DEFAULT_SERVER_PORT = 25565
|
|
||||||
const PORT_CHECK_INTERVAL_MS = 10_000
|
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
|
|
||||||
const portProcess = ref<PortProcessInfoData | null>(null)
|
|
||||||
const checkingPort = ref(false)
|
|
||||||
const killingPortProcess = ref(false)
|
|
||||||
let portCheckToken = 0
|
|
||||||
|
|
||||||
const effectivePort = computed(() =>
|
|
||||||
server.value ? (server.value.port ?? DEFAULT_SERVER_PORT) : null,
|
|
||||||
)
|
|
||||||
const portConflict = computed(
|
|
||||||
() => !!server.value && server.value.status !== 'running' && !!portProcess.value,
|
|
||||||
)
|
|
||||||
const occupyingProcessLabel = computed(() => {
|
|
||||||
const info = portProcess.value
|
|
||||||
if (!info) return ''
|
|
||||||
return info.name
|
|
||||||
? `${info.name} (PID ${info.pid})`
|
|
||||||
: formatMessage(messages.portUnknownProcess, { pid: info.pid })
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Polls whether something else is listening on the server's port. */
|
|
||||||
async function checkPortOccupation(silent = true) {
|
|
||||||
const port = effectivePort.value
|
|
||||||
if (port == null || server.value?.status === 'running') {
|
|
||||||
portCheckToken++
|
|
||||||
portProcess.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!silent) checkingPort.value = true
|
|
||||||
const token = ++portCheckToken
|
|
||||||
try {
|
|
||||||
const info = await serversApi.portProcess(port)
|
|
||||||
if (token === portCheckToken) portProcess.value = info
|
|
||||||
} catch {
|
|
||||||
if (token === portCheckToken) portProcess.value = null
|
|
||||||
} finally {
|
|
||||||
if (!silent) checkingPort.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function recheckPort() {
|
|
||||||
void checkPortOccupation(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function forceQuitPortProcess() {
|
|
||||||
const port = effectivePort.value
|
|
||||||
if (port == null || killingPortProcess.value) return
|
|
||||||
killingPortProcess.value = true
|
|
||||||
try {
|
|
||||||
await serversApi.killPortProcess(port)
|
|
||||||
await checkPortOccupation()
|
|
||||||
} catch (error) {
|
|
||||||
addNotification({
|
|
||||||
title: formatMessage(messages.portForceQuitFailed),
|
|
||||||
text: error instanceof Error ? error.message : String(error),
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
killingPortProcess.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opens the settings tab and focuses the port field once the properties editor has loaded. */
|
|
||||||
async function goToPortSetting() {
|
|
||||||
tabIndex.value = 2
|
|
||||||
let portField: HTMLElement | null = null
|
|
||||||
for (let i = 0; i < 20 && !portField; i++) {
|
|
||||||
await nextTick()
|
|
||||||
portField = document.getElementById('server-prop-server-port')
|
|
||||||
if (!portField) await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
}
|
|
||||||
if (portField) {
|
|
||||||
portField.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
portField.focus({ preventScroll: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([() => server.value?.status, effectivePort], () => void checkPortOccupation(), {
|
|
||||||
immediate: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const portCheckTimer = setInterval(() => void checkPortOccupation(), PORT_CHECK_INTERVAL_MS)
|
|
||||||
onUnmounted(() => clearInterval(portCheckTimer))
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (servers.value.length === 0) await refresh().catch(() => {})
|
|
||||||
isLoaded.value = true
|
|
||||||
})
|
|
||||||
|
|
||||||
// A server disappearing after it was loaded means it was deleted: go back to the list
|
|
||||||
// instead of showing a "no longer exists" dead end.
|
|
||||||
watch([server, isLoaded], ([value, loaded]) => {
|
|
||||||
if (value) {
|
|
||||||
hasSeenServer.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (loaded && hasSeenServer.value) void router.replace('/multiplayer/servers')
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabIndex = ref(route.query.tab === 'files' ? 1 : route.query.tab === 'settings' ? 2 : 0)
|
|
||||||
const tabLinks = computed(() => [
|
|
||||||
{ label: formatMessage(messages.console), href: 'console', icon: TerminalSquareIcon },
|
|
||||||
{ label: formatMessage(messages.files), href: 'files', icon: FolderOpenIcon },
|
|
||||||
{ label: formatMessage(messages.settings), href: 'settings', icon: WrenchIcon },
|
|
||||||
])
|
|
||||||
|
|
||||||
async function toggleRunning() {
|
|
||||||
if (!server.value) return
|
|
||||||
if (server.value.status === 'running') {
|
|
||||||
await stopServer(server.value.id)
|
|
||||||
} else {
|
|
||||||
await tryStartServer(server.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setServerIcon() {
|
|
||||||
if (!server.value) return
|
|
||||||
try {
|
|
||||||
const picked = await (filePicker.pickInstanceIcon?.() ?? filePicker.pickImage())
|
|
||||||
if (!picked?.path) return
|
|
||||||
await serversApi.setIcon(server.value.id, picked.path)
|
|
||||||
await refresh()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetServerIcon() {
|
|
||||||
if (!server.value?.iconPath) return
|
|
||||||
try {
|
|
||||||
await serversApi.setIcon(server.value.id, null)
|
|
||||||
await refresh()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shareOnline() {
|
|
||||||
if (!server.value?.port) return
|
|
||||||
await router.push({ path: '/multiplayer/rooms' })
|
|
||||||
void multiplayerSession.hostHongshi(server.value.port, null, null)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="multiplayer-fixed-render flex h-full min-h-0 w-full flex-col gap-3">
|
|
||||||
<div v-if="!server && isLoaded && !hasSeenServer" class="text-secondary">
|
|
||||||
{{ formatMessage(messages.notFound) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else-if="server">
|
|
||||||
<div class="flex min-w-0 shrink-0 flex-wrap items-center justify-between gap-3">
|
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
|
||||||
<ButtonStyled type="transparent" circular>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:aria-label="formatMessage(messages.back)"
|
|
||||||
@click="router.push('/multiplayer/servers')"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<div class="group relative shrink-0">
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(messages.editIcon)"
|
|
||||||
type="button"
|
|
||||||
class="cursor-pointer rounded-xl transition-transform group-active:scale-95"
|
|
||||||
:aria-label="formatMessage(messages.editIcon)"
|
|
||||||
@click="setServerIcon"
|
|
||||||
>
|
|
||||||
<ServerIcon
|
|
||||||
:icon-path="server.iconPath"
|
|
||||||
:server-type="server.serverType"
|
|
||||||
:server-id="server.id"
|
|
||||||
size="44px"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<OverflowMenu
|
|
||||||
v-if="server.iconPath"
|
|
||||||
class="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-surface-4 text-secondary shadow-md transition-colors hover:text-contrast"
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'remove',
|
|
||||||
color: 'danger',
|
|
||||||
action: () => resetServerIcon(),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<MoreVerticalIcon class="size-3.5" />
|
|
||||||
</OverflowMenu>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
|
||||||
<h2 class="m-0 truncate text-xl font-semibold text-contrast">
|
|
||||||
{{ server.name }}
|
|
||||||
</h2>
|
|
||||||
<TagItem v-if="displayTag" class="shrink-0">
|
|
||||||
<span :class="`font-semibold ${displayTag.color}`">
|
|
||||||
{{ formatMessage(displayTag.label) }}
|
|
||||||
</span>
|
|
||||||
</TagItem>
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-sm text-secondary">
|
|
||||||
<span class="truncate">
|
|
||||||
{{
|
|
||||||
formatMessage(messages.typeLabel, {
|
|
||||||
type: server.serverType,
|
|
||||||
version: server.gameVersion,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<span v-if="server.port" class="shrink-0">
|
|
||||||
{{ formatMessage(messages.port, { port: server.port }) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<ButtonStyled v-if="server.status === 'running'" color="red" type="outlined">
|
|
||||||
<button type="button" @click="toggleRunning">
|
|
||||||
<StopCircleIcon />
|
|
||||||
{{ formatMessage(messages.stop) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else-if="setupStatus === 'installing'" type="outlined">
|
|
||||||
<button type="button" disabled>
|
|
||||||
<LoaderCircleIcon class="animate-spin" />
|
|
||||||
{{ formatMessage(messages.downloading) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else-if="setupStatus === 'interrupted'" color="brand">
|
|
||||||
<button type="button" @click="resumeInstall(server)">
|
|
||||||
<DownloadIcon />
|
|
||||||
{{ formatMessage(messages.continueDownload) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else-if="setupStatus === 'failed'" color="brand">
|
|
||||||
<button type="button" @click="resumeInstall(server)">
|
|
||||||
<RefreshCwIcon />
|
|
||||||
{{ formatMessage(messages.retryDownload) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else-if="!portConflict" color="brand">
|
|
||||||
<button type="button" @click="toggleRunning">
|
|
||||||
<PlayIcon />
|
|
||||||
{{ formatMessage(messages.start) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-if="server.status === 'running' && server.port" type="outlined">
|
|
||||||
<button type="button" @click="shareOnline">
|
|
||||||
<GlobeIcon />
|
|
||||||
{{ formatMessage(messages.share) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled type="outlined">
|
|
||||||
<button type="button" @click="openPath(server.path)">
|
|
||||||
<FolderOpenIcon />
|
|
||||||
{{ formatMessage(messages.openFolder) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Admonition
|
|
||||||
v-if="portConflict && portProcess"
|
|
||||||
type="warning"
|
|
||||||
:header="formatMessage(messages.portConflictTitle, { port: effectivePort })"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
formatMessage(messages.portConflictDescription, {
|
|
||||||
process: occupyingProcessLabel,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
<template #actions>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button type="button" :disabled="killingPortProcess" @click="goToPortSetting">
|
|
||||||
<PencilIcon />
|
|
||||||
{{ formatMessage(messages.portChange) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="red">
|
|
||||||
<button type="button" :disabled="killingPortProcess" @click="forceQuitPortProcess">
|
|
||||||
<LoaderCircleIcon v-if="killingPortProcess" class="animate-spin" />
|
|
||||||
<ShieldIcon v-else />
|
|
||||||
{{ formatMessage(messages.portForceQuit) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button type="button" :disabled="checkingPort" @click="recheckPort">
|
|
||||||
<LoaderCircleIcon v-if="checkingPort" class="animate-spin" />
|
|
||||||
<RefreshCwIcon v-else />
|
|
||||||
{{ formatMessage(messages.portRecheck) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Admonition>
|
|
||||||
|
|
||||||
<Admonition
|
|
||||||
v-if="setupStatus === 'failed' && server.installError"
|
|
||||||
type="warning"
|
|
||||||
:header="formatMessage(messages.downloadFailed)"
|
|
||||||
>
|
|
||||||
{{ server.installError }}
|
|
||||||
</Admonition>
|
|
||||||
|
|
||||||
<NavTabs
|
|
||||||
mode="local"
|
|
||||||
:active-index="tabIndex"
|
|
||||||
:links="tabLinks"
|
|
||||||
@tab-click="tabIndex = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="tabIndex === 0" class="min-h-0 flex-1">
|
|
||||||
<ServerConsole :server="server" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="tabIndex === 1" class="min-h-0 flex-1 overflow-y-auto pr-1">
|
|
||||||
<ServerFilesPanel :server="server" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="min-h-0 flex-1 overflow-y-auto pr-1">
|
|
||||||
<ServerSettingsPanel :server="server" @deleted="router.push('/multiplayer/servers')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EulaModal ref="eulaModal" :text="eulaText" @continue="acceptEula" @decline="declineEula" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/*
|
|
||||||
* fixed 渲染模式(服务器详情页):控制台/设置区内部滚动。
|
|
||||||
* page-transition-grid 与 page-transition-layer 显式定高(100%)且允许
|
|
||||||
* 收缩(min-height: 0)。grid 必须显式声明 minmax(0, 1fr) 行——隐式 auto 行
|
|
||||||
* 以内容自适应,行高不 definite 时 layer 的百分比高度会退化为 auto,
|
|
||||||
* 整条 h-full 链随之失效,日志一多终端就会把页面撑出视口。
|
|
||||||
* app-viewport 保留 overflow: auto 作为兜底:控制台区块在有日志时固定为
|
|
||||||
* calc(100dvh - 80px),高于可视剩余空间,页面需要可以滚动露出命令输入框;
|
|
||||||
* scrollbar-gutter: auto 避免滚动条出现/消失时布局跳动。
|
|
||||||
*/
|
|
||||||
.app-viewport:has(.multiplayer-fixed-render) {
|
|
||||||
scrollbar-gutter: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-viewport:has(.multiplayer-fixed-render) .page-transition-grid,
|
|
||||||
.app-viewport:has(.multiplayer-fixed-render) .page-transition-layer {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-viewport:has(.multiplayer-fixed-render) .page-transition-grid {
|
|
||||||
grid-template-rows: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Admonition, defineMessages, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
import { useServers } from '@/composables/useServers'
|
|
||||||
import FileStudio from '@/pages/instance/FileStudio.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const serverId = route.params.id as string
|
|
||||||
const { servers, refresh } = useServers()
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const isLoaded = ref(false)
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
notFound: {
|
|
||||||
id: 'app.servers.detail.not-found',
|
|
||||||
defaultMessage: 'This server no longer exists.',
|
|
||||||
},
|
|
||||||
runningTitle: {
|
|
||||||
id: 'app.servers.files.studio-running-title',
|
|
||||||
defaultMessage: 'Stop the server before opening Studio',
|
|
||||||
},
|
|
||||||
runningDescription: {
|
|
||||||
id: 'app.servers.files.busy-tooltip',
|
|
||||||
defaultMessage: 'Stop the server to modify files',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = computed(() => servers.value.find((entry) => entry.id === serverId))
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (servers.value.length === 0) await refresh()
|
|
||||||
isLoaded.value = true
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FileStudio v-if="server && !server.running" :server="server" />
|
|
||||||
<div v-else-if="server" class="flex size-full items-center justify-center p-6">
|
|
||||||
<Admonition type="warning" :header="formatMessage(messages.runningTitle)">
|
|
||||||
{{ formatMessage(messages.runningDescription) }}
|
|
||||||
</Admonition>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="isLoaded" class="flex size-full items-center justify-center text-secondary">
|
|
||||||
{{ formatMessage(messages.notFound) }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,265 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { CodeIcon } from '@modrinth/assets'
|
|
||||||
import type { EditingFile, FileItem } from '@modrinth/ui'
|
|
||||||
import {
|
|
||||||
ButtonStyled,
|
|
||||||
commonMessages,
|
|
||||||
defineMessages,
|
|
||||||
FilePageLayout,
|
|
||||||
injectNotificationManager,
|
|
||||||
provideFileManager,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { save } from '@tauri-apps/plugin-dialog'
|
|
||||||
import { join } from '@tauri-apps/api/path'
|
|
||||||
import {
|
|
||||||
copyFile,
|
|
||||||
mkdir,
|
|
||||||
readDir,
|
|
||||||
readFile as readFileBytes,
|
|
||||||
readTextFile,
|
|
||||||
remove,
|
|
||||||
rename,
|
|
||||||
stat,
|
|
||||||
writeTextFile,
|
|
||||||
} from '@tauri-apps/plugin-fs'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import type { ServerView } from '@/composables/useServers'
|
|
||||||
import { highlightInFolder } from '@/helpers/utils'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
server: ServerView
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
saveAs: {
|
|
||||||
id: 'app.servers.files.save-as',
|
|
||||||
defaultMessage: 'Save as...',
|
|
||||||
},
|
|
||||||
busyTooltip: {
|
|
||||||
id: 'app.servers.files.busy-tooltip',
|
|
||||||
defaultMessage: 'Stop the server to modify files',
|
|
||||||
},
|
|
||||||
openStudio: {
|
|
||||||
id: 'app.servers.files.open-studio',
|
|
||||||
defaultMessage: 'Open Studio',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = ref<FileItem[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref<Error | null>(null)
|
|
||||||
const currentPath = ref('')
|
|
||||||
const editingFile = ref<EditingFile | null>(null)
|
|
||||||
|
|
||||||
const serverRoot = computed(() => props.server.path)
|
|
||||||
const isBusy = computed(() => props.server.running)
|
|
||||||
|
|
||||||
async function resolvePath(relativePath: string): Promise<string> {
|
|
||||||
const clean = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath
|
|
||||||
return clean ? join(serverRoot.value, ...clean.split('/')) : serverRoot.value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listDirectory(dirPath: string): Promise<FileItem[]> {
|
|
||||||
const absPath = await resolvePath(dirPath)
|
|
||||||
const entries = await readDir(absPath)
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
entries.map(async (entry) => {
|
|
||||||
const entryAbsPath = await join(absPath, entry.name)
|
|
||||||
let metadata
|
|
||||||
try {
|
|
||||||
metadata = await stat(entryAbsPath)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const item: FileItem = {
|
|
||||||
name: entry.name,
|
|
||||||
type: entry.isDirectory ? 'directory' : 'file',
|
|
||||||
path: dirPath ? `${dirPath}/${entry.name}` : entry.name,
|
|
||||||
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : 0,
|
|
||||||
created: metadata.birthtime ? Math.floor(metadata.birthtime.getTime() / 1000) : 0,
|
|
||||||
}
|
|
||||||
if (!entry.isDirectory) {
|
|
||||||
item.size = metadata.size
|
|
||||||
}
|
|
||||||
if (entry.isDirectory) {
|
|
||||||
try {
|
|
||||||
const children = await readDir(entryAbsPath)
|
|
||||||
item.count = children.length
|
|
||||||
} catch {
|
|
||||||
item.count = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return results.filter((item): item is FileItem => item !== null)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
items.value = await listDirectory(currentPath.value)
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e : new Error(String(e))
|
|
||||||
items.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateTo(path: string) {
|
|
||||||
currentPath.value = path.startsWith('/') ? path.slice(1) : path
|
|
||||||
void refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditing(file: EditingFile) {
|
|
||||||
editingFile.value = file
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopEditing() {
|
|
||||||
editingFile.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyFailure(label: string, e: unknown) {
|
|
||||||
addNotification({
|
|
||||||
title: label,
|
|
||||||
text: e instanceof Error ? e.message : '',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateItem(name: string, type: 'file' | 'directory') {
|
|
||||||
const targetPath = currentPath.value ? `${currentPath.value}/${name}` : name
|
|
||||||
const absPath = await resolvePath(targetPath)
|
|
||||||
try {
|
|
||||||
if (type === 'directory') {
|
|
||||||
await mkdir(absPath)
|
|
||||||
} else {
|
|
||||||
await writeTextFile(absPath, '')
|
|
||||||
}
|
|
||||||
await refresh()
|
|
||||||
} catch (e) {
|
|
||||||
notifyFailure(formatMessage(commonMessages.createFailedLabel), e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRenameItem(path: string, newName: string) {
|
|
||||||
const oldAbs = await resolvePath(path)
|
|
||||||
const parentDir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''
|
|
||||||
const newPath = parentDir ? `${parentDir}/${newName}` : newName
|
|
||||||
try {
|
|
||||||
await rename(oldAbs, await resolvePath(newPath))
|
|
||||||
await refresh()
|
|
||||||
} catch (e) {
|
|
||||||
notifyFailure(formatMessage(commonMessages.renameFailedLabel), e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMoveItem(source: string, destination: string) {
|
|
||||||
try {
|
|
||||||
await rename(await resolvePath(source), await resolvePath(destination))
|
|
||||||
await refresh()
|
|
||||||
} catch (e) {
|
|
||||||
notifyFailure(formatMessage(commonMessages.moveFailedLabel), e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteItem(path: string, recursive: boolean) {
|
|
||||||
try {
|
|
||||||
await remove(await resolvePath(path), { recursive })
|
|
||||||
await refresh()
|
|
||||||
} catch (e) {
|
|
||||||
notifyFailure(formatMessage(commonMessages.deleteFailedLabel), e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleReadFile(path: string): Promise<string> {
|
|
||||||
return await readTextFile(await resolvePath(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleReadFileAsBlob(path: string): Promise<Blob> {
|
|
||||||
const bytes = await readFileBytes(await resolvePath(path))
|
|
||||||
return new Blob([bytes])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleWriteFile(path: string, content: string) {
|
|
||||||
await writeTextFile(await resolvePath(path), content)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDownloadFile(path: string, fileName: string) {
|
|
||||||
const outputPath = await save({ defaultPath: fileName })
|
|
||||||
if (!outputPath) return
|
|
||||||
await copyFile(await resolvePath(path), outputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.server.path,
|
|
||||||
async () => {
|
|
||||||
currentPath.value = ''
|
|
||||||
await refresh()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
provideFileManager({
|
|
||||||
items,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
currentPath,
|
|
||||||
navigateTo,
|
|
||||||
editingFile,
|
|
||||||
startEditing,
|
|
||||||
stopEditing,
|
|
||||||
createItem: handleCreateItem,
|
|
||||||
renameItem: handleRenameItem,
|
|
||||||
moveItem: handleMoveItem,
|
|
||||||
deleteItem: handleDeleteItem,
|
|
||||||
readFile: handleReadFile,
|
|
||||||
readFileAsBlob: handleReadFileAsBlob,
|
|
||||||
writeFile: handleWriteFile,
|
|
||||||
downloadFile: handleDownloadFile,
|
|
||||||
refresh,
|
|
||||||
isBusy,
|
|
||||||
busyTooltip: computed(() => (isBusy.value ? formatMessage(messages.busyTooltip) : undefined)),
|
|
||||||
basePath: serverRoot,
|
|
||||||
openInFolder: (path: string) => highlightInFolder(path),
|
|
||||||
downloadButtonLabel: formatMessage(messages.saveAs),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-0 w-full">
|
|
||||||
<FilePageLayout :show-refresh-button="true">
|
|
||||||
<template #before-refresh>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button
|
|
||||||
v-tooltip="isBusy ? formatMessage(messages.busyTooltip) : undefined"
|
|
||||||
type="button"
|
|
||||||
class="!h-10"
|
|
||||||
:disabled="isBusy"
|
|
||||||
@click="router.push({ name: 'MultiplayerServerFileStudio', params: { id: server.id } })"
|
|
||||||
>
|
|
||||||
<CodeIcon class="size-5" />
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
{{ formatMessage(messages.openStudio) }}
|
|
||||||
<span
|
|
||||||
class="rounded bg-orange px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none text-contrast"
|
|
||||||
>
|
|
||||||
Beta
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</FilePageLayout>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ServerTypeId } from '@modrinth/server'
|
|
||||||
import { Avatar } from '@modrinth/ui'
|
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import { SERVER_TYPE_META } from '@/components/multiplayer/servers/server-type'
|
|
||||||
import { isBuiltInInstanceIcon } from '@/helpers/instance-icon-frame'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
iconPath?: string | null
|
|
||||||
serverType: ServerTypeId
|
|
||||||
serverId?: string | null
|
|
||||||
size?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
iconPath: null,
|
|
||||||
serverId: null,
|
|
||||||
size: '2rem',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const iconUrl = computed(() => (props.iconPath ? convertFileSrc(props.iconPath) : null))
|
|
||||||
|
|
||||||
const typeMeta = computed(() => SERVER_TYPE_META[props.serverType])
|
|
||||||
|
|
||||||
// User-set or built-in icon path takes priority; otherwise fall back to the
|
|
||||||
// per-type brand icon (e.g. Mojang/Forge/Fabric/Paper). Brand icons render frameless.
|
|
||||||
const displayUrl = computed(() => iconUrl.value ?? typeMeta.value.icon ?? null)
|
|
||||||
const frameless = computed(() => {
|
|
||||||
if (props.iconPath) return isBuiltInInstanceIcon(props.iconPath)
|
|
||||||
return !!typeMeta.value.icon
|
|
||||||
})
|
|
||||||
|
|
||||||
// Inline styles instead of Tailwind arbitrary values: underscores inside
|
|
||||||
// `var(--_color)` are converted to spaces by Tailwind's arbitrary-value
|
|
||||||
// parsing, which generates invalid CSS and breaks the production build.
|
|
||||||
const monogramStyle = computed(() => ({
|
|
||||||
color: typeMeta.value.colorVar,
|
|
||||||
backgroundColor: `color-mix(in srgb, ${typeMeta.value.colorVar} 14%, transparent)`,
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Avatar
|
|
||||||
v-if="displayUrl"
|
|
||||||
:src="displayUrl"
|
|
||||||
:size="size"
|
|
||||||
:tint-by="serverId"
|
|
||||||
:class="{ '!border-0 !rounded-none !bg-transparent !shadow-none': frameless }"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex shrink-0 items-center justify-center rounded-lg text-xs font-bold"
|
|
||||||
:style="{
|
|
||||||
'--_size': size,
|
|
||||||
width: 'var(--_size)',
|
|
||||||
height: 'var(--_size)',
|
|
||||||
fontSize: 'calc(var(--_size) * 0.375)',
|
|
||||||
...monogramStyle,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ typeMeta.monogram }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,741 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
DropdownIcon,
|
|
||||||
FileTextIcon,
|
|
||||||
GameIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
MapIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
PackageIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
configFieldLabel,
|
|
||||||
getConfigFile,
|
|
||||||
parseProperties,
|
|
||||||
type PropertiesEntry,
|
|
||||||
resolveConfigField,
|
|
||||||
type ResolvedConfigField,
|
|
||||||
serializeProperties,
|
|
||||||
setProperty,
|
|
||||||
} from '@modrinth/server'
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
ButtonStyled,
|
|
||||||
defineMessages,
|
|
||||||
DropdownSelect,
|
|
||||||
injectNotificationManager,
|
|
||||||
type MessageDescriptor,
|
|
||||||
StyledInput,
|
|
||||||
Toggle,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { type Component, computed, onMounted, ref } from 'vue'
|
|
||||||
|
|
||||||
import StudioEditor from '@/components/instance/studio/StudioEditor.vue'
|
|
||||||
import { servers } from '@/helpers/servers'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
serverId: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: { id: 'app.servers.properties.title', defaultMessage: 'Server properties' },
|
|
||||||
formMode: { id: 'app.servers.properties.mode.form', defaultMessage: 'Form' },
|
|
||||||
textMode: { id: 'app.servers.properties.mode.text', defaultMessage: 'Text' },
|
|
||||||
missing: {
|
|
||||||
id: 'app.servers.properties.missing',
|
|
||||||
defaultMessage: 'Start the server once to generate this file.',
|
|
||||||
},
|
|
||||||
loadFailed: {
|
|
||||||
id: 'app.servers.properties.load-failed',
|
|
||||||
defaultMessage: 'Failed to load the server configuration.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const fieldMessages = defineMessages({
|
|
||||||
'server-port': { id: 'app.servers.properties.field.server-port', defaultMessage: 'Server port' },
|
|
||||||
difficulty: { id: 'app.servers.properties.field.difficulty', defaultMessage: 'Difficulty' },
|
|
||||||
gamemode: { id: 'app.servers.properties.field.gamemode', defaultMessage: 'Game mode' },
|
|
||||||
'level-type': { id: 'app.servers.properties.field.level-type', defaultMessage: 'Level type' },
|
|
||||||
'max-players': {
|
|
||||||
id: 'app.servers.properties.field.max-players',
|
|
||||||
defaultMessage: 'Max players',
|
|
||||||
},
|
|
||||||
'view-distance': {
|
|
||||||
id: 'app.servers.properties.field.view-distance',
|
|
||||||
defaultMessage: 'View distance',
|
|
||||||
},
|
|
||||||
'simulation-distance': {
|
|
||||||
id: 'app.servers.properties.field.simulation-distance',
|
|
||||||
defaultMessage: 'Simulation distance',
|
|
||||||
},
|
|
||||||
'max-tick-time': {
|
|
||||||
id: 'app.servers.properties.field.max-tick-time',
|
|
||||||
defaultMessage: 'Max tick time',
|
|
||||||
},
|
|
||||||
'max-world-size': {
|
|
||||||
id: 'app.servers.properties.field.max-world-size',
|
|
||||||
defaultMessage: 'Max world size',
|
|
||||||
},
|
|
||||||
'op-permission-level': {
|
|
||||||
id: 'app.servers.properties.field.op-permission-level',
|
|
||||||
defaultMessage: 'OP permission level',
|
|
||||||
},
|
|
||||||
'function-permission-level': {
|
|
||||||
id: 'app.servers.properties.field.function-permission-level',
|
|
||||||
defaultMessage: 'Function permission level',
|
|
||||||
},
|
|
||||||
'spawn-protection': {
|
|
||||||
id: 'app.servers.properties.field.spawn-protection',
|
|
||||||
defaultMessage: 'Spawn protection',
|
|
||||||
},
|
|
||||||
'player-idle-timeout': {
|
|
||||||
id: 'app.servers.properties.field.player-idle-timeout',
|
|
||||||
defaultMessage: 'Player idle timeout',
|
|
||||||
},
|
|
||||||
'network-compression-threshold': {
|
|
||||||
id: 'app.servers.properties.field.network-compression-threshold',
|
|
||||||
defaultMessage: 'Network compression threshold',
|
|
||||||
},
|
|
||||||
'rate-limit': { id: 'app.servers.properties.field.rate-limit', defaultMessage: 'Rate limit' },
|
|
||||||
'query.port': { id: 'app.servers.properties.field.query.port', defaultMessage: 'Query port' },
|
|
||||||
'rcon.port': { id: 'app.servers.properties.field.rcon.port', defaultMessage: 'RCON port' },
|
|
||||||
'level-name': { id: 'app.servers.properties.field.level-name', defaultMessage: 'Level name' },
|
|
||||||
'level-seed': { id: 'app.servers.properties.field.level-seed', defaultMessage: 'Level seed' },
|
|
||||||
motd: {
|
|
||||||
id: 'app.servers.properties.field.motd',
|
|
||||||
defaultMessage: 'Message of the day (MOTD)',
|
|
||||||
},
|
|
||||||
'resource-pack': {
|
|
||||||
id: 'app.servers.properties.field.resource-pack',
|
|
||||||
defaultMessage: 'Resource pack',
|
|
||||||
},
|
|
||||||
'resource-pack-sha1': {
|
|
||||||
id: 'app.servers.properties.field.resource-pack-sha1',
|
|
||||||
defaultMessage: 'Resource pack SHA-1',
|
|
||||||
},
|
|
||||||
'resource-pack-prompt': {
|
|
||||||
id: 'app.servers.properties.field.resource-pack-prompt',
|
|
||||||
defaultMessage: 'Resource pack prompt',
|
|
||||||
},
|
|
||||||
'rcon.password': {
|
|
||||||
id: 'app.servers.properties.field.rcon.password',
|
|
||||||
defaultMessage: 'RCON password',
|
|
||||||
},
|
|
||||||
'server-ip': { id: 'app.servers.properties.field.server-ip', defaultMessage: 'Server IP' },
|
|
||||||
'text-filtering-config': {
|
|
||||||
id: 'app.servers.properties.field.text-filtering-config',
|
|
||||||
defaultMessage: 'Text filtering config',
|
|
||||||
},
|
|
||||||
'initial-enabled-packs': {
|
|
||||||
id: 'app.servers.properties.field.initial-enabled-packs',
|
|
||||||
defaultMessage: 'Initial enabled packs',
|
|
||||||
},
|
|
||||||
'online-mode': {
|
|
||||||
id: 'app.servers.properties.field.online-mode',
|
|
||||||
defaultMessage: 'Online mode',
|
|
||||||
},
|
|
||||||
'white-list': { id: 'app.servers.properties.field.white-list', defaultMessage: 'Whitelist' },
|
|
||||||
'enforce-whitelist': {
|
|
||||||
id: 'app.servers.properties.field.enforce-whitelist',
|
|
||||||
defaultMessage: 'Enforce whitelist',
|
|
||||||
},
|
|
||||||
'enforce-secure-profile': {
|
|
||||||
id: 'app.servers.properties.field.enforce-secure-profile',
|
|
||||||
defaultMessage: 'Enforce secure profile',
|
|
||||||
},
|
|
||||||
'prevent-proxy-connections': {
|
|
||||||
id: 'app.servers.properties.field.prevent-proxy-connections',
|
|
||||||
defaultMessage: 'Prevent proxy connections',
|
|
||||||
},
|
|
||||||
'allow-flight': {
|
|
||||||
id: 'app.servers.properties.field.allow-flight',
|
|
||||||
defaultMessage: 'Allow flight',
|
|
||||||
},
|
|
||||||
'allow-nether': {
|
|
||||||
id: 'app.servers.properties.field.allow-nether',
|
|
||||||
defaultMessage: 'Allow the Nether',
|
|
||||||
},
|
|
||||||
'spawn-animals': {
|
|
||||||
id: 'app.servers.properties.field.spawn-animals',
|
|
||||||
defaultMessage: 'Spawn animals',
|
|
||||||
},
|
|
||||||
'spawn-monsters': {
|
|
||||||
id: 'app.servers.properties.field.spawn-monsters',
|
|
||||||
defaultMessage: 'Spawn monsters',
|
|
||||||
},
|
|
||||||
'spawn-npcs': { id: 'app.servers.properties.field.spawn-npcs', defaultMessage: 'Spawn NPCs' },
|
|
||||||
pvp: {
|
|
||||||
id: 'app.servers.properties.field.pvp',
|
|
||||||
defaultMessage: 'Player versus player (PvP)',
|
|
||||||
},
|
|
||||||
'enable-command-block': {
|
|
||||||
id: 'app.servers.properties.field.enable-command-block',
|
|
||||||
defaultMessage: 'Enable command blocks',
|
|
||||||
},
|
|
||||||
'enable-status': {
|
|
||||||
id: 'app.servers.properties.field.enable-status',
|
|
||||||
defaultMessage: 'Enable status',
|
|
||||||
},
|
|
||||||
'enable-query': {
|
|
||||||
id: 'app.servers.properties.field.enable-query',
|
|
||||||
defaultMessage: 'Enable query',
|
|
||||||
},
|
|
||||||
'enable-rcon': {
|
|
||||||
id: 'app.servers.properties.field.enable-rcon',
|
|
||||||
defaultMessage: 'Enable RCON',
|
|
||||||
},
|
|
||||||
'enable-jmx-monitoring': {
|
|
||||||
id: 'app.servers.properties.field.enable-jmx-monitoring',
|
|
||||||
defaultMessage: 'Enable JMX monitoring',
|
|
||||||
},
|
|
||||||
'force-gamemode': {
|
|
||||||
id: 'app.servers.properties.field.force-gamemode',
|
|
||||||
defaultMessage: 'Force game mode',
|
|
||||||
},
|
|
||||||
hardcore: { id: 'app.servers.properties.field.hardcore', defaultMessage: 'Hardcore' },
|
|
||||||
'announce-player-achievements': {
|
|
||||||
id: 'app.servers.properties.field.announce-player-achievements',
|
|
||||||
defaultMessage: 'Announce player achievements',
|
|
||||||
},
|
|
||||||
'log-ips': { id: 'app.servers.properties.field.log-ips', defaultMessage: 'Log IP addresses' },
|
|
||||||
'hide-online-players': {
|
|
||||||
id: 'app.servers.properties.field.hide-online-players',
|
|
||||||
defaultMessage: 'Hide online players',
|
|
||||||
},
|
|
||||||
'require-resource-pack': {
|
|
||||||
id: 'app.servers.properties.field.require-resource-pack',
|
|
||||||
defaultMessage: 'Require resource pack',
|
|
||||||
},
|
|
||||||
'sync-chunk-writes': {
|
|
||||||
id: 'app.servers.properties.field.sync-chunk-writes',
|
|
||||||
defaultMessage: 'Sync chunk writes',
|
|
||||||
},
|
|
||||||
'use-native-transport': {
|
|
||||||
id: 'app.servers.properties.field.use-native-transport',
|
|
||||||
defaultMessage: 'Use native transport',
|
|
||||||
},
|
|
||||||
'allow-end': {
|
|
||||||
id: 'app.servers.properties.field.allow-end',
|
|
||||||
defaultMessage: 'Allow the End',
|
|
||||||
},
|
|
||||||
'generate-structures': {
|
|
||||||
id: 'app.servers.properties.field.generate-structures',
|
|
||||||
defaultMessage: 'Generate structures',
|
|
||||||
},
|
|
||||||
'enable-lan': {
|
|
||||||
id: 'app.servers.properties.field.enable-lan',
|
|
||||||
defaultMessage: 'Enable LAN',
|
|
||||||
},
|
|
||||||
'accepts-transfers': {
|
|
||||||
id: 'app.servers.properties.field.accepts-transfers',
|
|
||||||
defaultMessage: 'Accept player transfers',
|
|
||||||
},
|
|
||||||
'broadcast-console-to-ops': {
|
|
||||||
id: 'app.servers.properties.field.broadcast-console-to-ops',
|
|
||||||
defaultMessage: 'Broadcast console to operators',
|
|
||||||
},
|
|
||||||
'broadcast-rcon-to-ops': {
|
|
||||||
id: 'app.servers.properties.field.broadcast-rcon-to-ops',
|
|
||||||
defaultMessage: 'Broadcast RCON to operators',
|
|
||||||
},
|
|
||||||
'bug-report-link': {
|
|
||||||
id: 'app.servers.properties.field.bug-report-link',
|
|
||||||
defaultMessage: 'Bug report link',
|
|
||||||
},
|
|
||||||
'chat-spam-threshold-seconds': {
|
|
||||||
id: 'app.servers.properties.field.chat-spam-threshold-seconds',
|
|
||||||
defaultMessage: 'Chat spam threshold (seconds)',
|
|
||||||
},
|
|
||||||
'command-spam-threshold-seconds': {
|
|
||||||
id: 'app.servers.properties.field.command-spam-threshold-seconds',
|
|
||||||
defaultMessage: 'Command spam threshold (seconds)',
|
|
||||||
},
|
|
||||||
'enable-code-of-conduct': {
|
|
||||||
id: 'app.servers.properties.field.enable-code-of-conduct',
|
|
||||||
defaultMessage: 'Enable code of conduct',
|
|
||||||
},
|
|
||||||
'entity-broadcast-range-percentage': {
|
|
||||||
id: 'app.servers.properties.field.entity-broadcast-range-percentage',
|
|
||||||
defaultMessage: 'Entity broadcast range percentage',
|
|
||||||
},
|
|
||||||
'generator-settings': {
|
|
||||||
id: 'app.servers.properties.field.generator-settings',
|
|
||||||
defaultMessage: 'Generator settings',
|
|
||||||
},
|
|
||||||
'initial-disabled-packs': {
|
|
||||||
id: 'app.servers.properties.field.initial-disabled-packs',
|
|
||||||
defaultMessage: 'Initial disabled packs',
|
|
||||||
},
|
|
||||||
'management-server-allowed-origins': {
|
|
||||||
id: 'app.servers.properties.field.management-server-allowed-origins',
|
|
||||||
defaultMessage: 'Management server allowed origins',
|
|
||||||
},
|
|
||||||
'management-server-enabled': {
|
|
||||||
id: 'app.servers.properties.field.management-server-enabled',
|
|
||||||
defaultMessage: 'Enable management server',
|
|
||||||
},
|
|
||||||
'management-server-host': {
|
|
||||||
id: 'app.servers.properties.field.management-server-host',
|
|
||||||
defaultMessage: 'Management server host',
|
|
||||||
},
|
|
||||||
'management-server-port': {
|
|
||||||
id: 'app.servers.properties.field.management-server-port',
|
|
||||||
defaultMessage: 'Management server port',
|
|
||||||
},
|
|
||||||
'management-server-secret': {
|
|
||||||
id: 'app.servers.properties.field.management-server-secret',
|
|
||||||
defaultMessage: 'Management server secret',
|
|
||||||
},
|
|
||||||
'management-server-tls-enabled': {
|
|
||||||
id: 'app.servers.properties.field.management-server-tls-enabled',
|
|
||||||
defaultMessage: 'Enable management server TLS',
|
|
||||||
},
|
|
||||||
'management-server-tls-keystore': {
|
|
||||||
id: 'app.servers.properties.field.management-server-tls-keystore',
|
|
||||||
defaultMessage: 'Management server TLS keystore',
|
|
||||||
},
|
|
||||||
'management-server-tls-keystore-password': {
|
|
||||||
id: 'app.servers.properties.field.management-server-tls-keystore-password',
|
|
||||||
defaultMessage: 'Management server TLS keystore password',
|
|
||||||
},
|
|
||||||
'max-chained-neighbor-updates': {
|
|
||||||
id: 'app.servers.properties.field.max-chained-neighbor-updates',
|
|
||||||
defaultMessage: 'Max chained neighbor updates',
|
|
||||||
},
|
|
||||||
'pause-when-empty-seconds': {
|
|
||||||
id: 'app.servers.properties.field.pause-when-empty-seconds',
|
|
||||||
defaultMessage: 'Pause when empty (seconds)',
|
|
||||||
},
|
|
||||||
'region-file-compression': {
|
|
||||||
id: 'app.servers.properties.field.region-file-compression',
|
|
||||||
defaultMessage: 'Region file compression',
|
|
||||||
},
|
|
||||||
'resource-pack-id': {
|
|
||||||
id: 'app.servers.properties.field.resource-pack-id',
|
|
||||||
defaultMessage: 'Resource pack ID',
|
|
||||||
},
|
|
||||||
'status-heartbeat-interval': {
|
|
||||||
id: 'app.servers.properties.field.status-heartbeat-interval',
|
|
||||||
defaultMessage: 'Status heartbeat interval',
|
|
||||||
},
|
|
||||||
'text-filtering-version': {
|
|
||||||
id: 'app.servers.properties.field.text-filtering-version',
|
|
||||||
defaultMessage: 'Text filtering version',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const sectionMessages = defineMessages({
|
|
||||||
network: {
|
|
||||||
id: 'app.servers.properties.section.network',
|
|
||||||
defaultMessage: 'Network & Security',
|
|
||||||
},
|
|
||||||
world: { id: 'app.servers.properties.section.world', defaultMessage: 'World' },
|
|
||||||
gameplay: { id: 'app.servers.properties.section.gameplay', defaultMessage: 'Gameplay' },
|
|
||||||
content: { id: 'app.servers.properties.section.content', defaultMessage: 'Content' },
|
|
||||||
advanced: { id: 'app.servers.properties.section.advanced', defaultMessage: 'Advanced' },
|
|
||||||
others: { id: 'app.servers.properties.section.others', defaultMessage: 'Other' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const SECTION_ICONS = {
|
|
||||||
network: GlobeIcon,
|
|
||||||
world: MapIcon,
|
|
||||||
gameplay: GameIcon,
|
|
||||||
content: PackageIcon,
|
|
||||||
advanced: SettingsIcon,
|
|
||||||
others: MoreHorizontalIcon,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const FIELD_SECTIONS = [
|
|
||||||
{
|
|
||||||
title: sectionMessages.network,
|
|
||||||
icon: SECTION_ICONS.network,
|
|
||||||
openByDefault: true,
|
|
||||||
fields: [
|
|
||||||
'server-port',
|
|
||||||
'server-ip',
|
|
||||||
'motd',
|
|
||||||
'max-players',
|
|
||||||
'online-mode',
|
|
||||||
'white-list',
|
|
||||||
'enforce-whitelist',
|
|
||||||
'enforce-secure-profile',
|
|
||||||
'prevent-proxy-connections',
|
|
||||||
'hide-online-players',
|
|
||||||
'enable-status',
|
|
||||||
'enable-query',
|
|
||||||
'query.port',
|
|
||||||
'enable-rcon',
|
|
||||||
'rcon.port',
|
|
||||||
'rcon.password',
|
|
||||||
'enable-lan',
|
|
||||||
'accepts-transfers',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: sectionMessages.world,
|
|
||||||
icon: SECTION_ICONS.world,
|
|
||||||
openByDefault: true,
|
|
||||||
fields: [
|
|
||||||
'level-name',
|
|
||||||
'level-seed',
|
|
||||||
'level-type',
|
|
||||||
'generator-settings',
|
|
||||||
'generate-structures',
|
|
||||||
'spawn-protection',
|
|
||||||
'allow-nether',
|
|
||||||
'allow-end',
|
|
||||||
'allow-flight',
|
|
||||||
'view-distance',
|
|
||||||
'simulation-distance',
|
|
||||||
'entity-broadcast-range-percentage',
|
|
||||||
'max-world-size',
|
|
||||||
'max-chained-neighbor-updates',
|
|
||||||
'region-file-compression',
|
|
||||||
'sync-chunk-writes',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: sectionMessages.gameplay,
|
|
||||||
icon: SECTION_ICONS.gameplay,
|
|
||||||
openByDefault: true,
|
|
||||||
fields: [
|
|
||||||
'gamemode',
|
|
||||||
'force-gamemode',
|
|
||||||
'difficulty',
|
|
||||||
'hardcore',
|
|
||||||
'pvp',
|
|
||||||
'spawn-animals',
|
|
||||||
'spawn-monsters',
|
|
||||||
'spawn-npcs',
|
|
||||||
'enable-command-block',
|
|
||||||
'announce-player-achievements',
|
|
||||||
'player-idle-timeout',
|
|
||||||
'pause-when-empty-seconds',
|
|
||||||
'max-tick-time',
|
|
||||||
'op-permission-level',
|
|
||||||
'function-permission-level',
|
|
||||||
'network-compression-threshold',
|
|
||||||
'rate-limit',
|
|
||||||
'chat-spam-threshold-seconds',
|
|
||||||
'command-spam-threshold-seconds',
|
|
||||||
'bug-report-link',
|
|
||||||
'use-native-transport',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: sectionMessages.content,
|
|
||||||
icon: SECTION_ICONS.content,
|
|
||||||
openByDefault: false,
|
|
||||||
fields: [
|
|
||||||
'resource-pack',
|
|
||||||
'resource-pack-id',
|
|
||||||
'resource-pack-sha1',
|
|
||||||
'resource-pack-prompt',
|
|
||||||
'require-resource-pack',
|
|
||||||
'initial-enabled-packs',
|
|
||||||
'initial-disabled-packs',
|
|
||||||
'enable-code-of-conduct',
|
|
||||||
'text-filtering-config',
|
|
||||||
'text-filtering-version',
|
|
||||||
'log-ips',
|
|
||||||
'broadcast-console-to-ops',
|
|
||||||
'broadcast-rcon-to-ops',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: sectionMessages.advanced,
|
|
||||||
icon: SECTION_ICONS.advanced,
|
|
||||||
openByDefault: false,
|
|
||||||
fields: [
|
|
||||||
'management-server-enabled',
|
|
||||||
'management-server-host',
|
|
||||||
'management-server-port',
|
|
||||||
'management-server-secret',
|
|
||||||
'management-server-allowed-origins',
|
|
||||||
'management-server-tls-enabled',
|
|
||||||
'management-server-tls-keystore',
|
|
||||||
'management-server-tls-keystore-password',
|
|
||||||
'status-heartbeat-interval',
|
|
||||||
'enable-jmx-monitoring',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const FILE_NAME = 'server.properties'
|
|
||||||
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const isMissing = ref(false)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
const mode = ref<'form' | 'text'>('form')
|
|
||||||
const entries = ref<PropertiesEntry[]>([])
|
|
||||||
const rawText = ref('')
|
|
||||||
const baselineText = ref('')
|
|
||||||
const normalizedBaseline = ref('')
|
|
||||||
const { handleError } = injectNotificationManager()
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
isLoading.value = true
|
|
||||||
isMissing.value = false
|
|
||||||
try {
|
|
||||||
const text = await servers.readFile(props.serverId, FILE_NAME)
|
|
||||||
entries.value = parseProperties(text)
|
|
||||||
rawText.value = text
|
|
||||||
baselineText.value = text
|
|
||||||
normalizedBaseline.value = serializeProperties(entries.value)
|
|
||||||
} catch {
|
|
||||||
isMissing.value = true
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
|
|
||||||
const definition = computed(() => getConfigFile(FILE_NAME))
|
|
||||||
|
|
||||||
const isDirty = computed(() =>
|
|
||||||
mode.value === 'text'
|
|
||||||
? rawText.value !== baselineText.value
|
|
||||||
: serializeProperties(entries.value) !== normalizedBaseline.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
function fieldLabel(key: string): string {
|
|
||||||
const descriptor = fieldMessages[key as keyof typeof fieldMessages]
|
|
||||||
return descriptor ? formatMessage(descriptor) : configFieldLabel(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormField {
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
field: ResolvedConfigField
|
|
||||||
}
|
|
||||||
|
|
||||||
const allFormFields = computed<FormField[]>(() =>
|
|
||||||
entries.value
|
|
||||||
.map((entry) => (entry.type === 'pair' ? entry : null))
|
|
||||||
.filter((entry): entry is Extract<PropertiesEntry, { type: 'pair' }> => entry !== null)
|
|
||||||
.map((pair) => ({
|
|
||||||
key: pair.key,
|
|
||||||
value: pair.value,
|
|
||||||
field: definition.value
|
|
||||||
? resolveConfigField(definition.value, pair.key, pair.value)
|
|
||||||
: { key: pair.key, kind: 'string' as const, inferred: true },
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const formSections = computed(() => {
|
|
||||||
const knownKeys = new Set(FIELD_SECTIONS.flatMap((section) => section.fields))
|
|
||||||
const byKey = new Map(allFormFields.value.map((field) => [field.key, field]))
|
|
||||||
const sections: {
|
|
||||||
title: MessageDescriptor
|
|
||||||
icon: Component
|
|
||||||
openByDefault: boolean
|
|
||||||
fields: FormField[]
|
|
||||||
}[] = FIELD_SECTIONS.flatMap((section) => {
|
|
||||||
const fields = section.fields
|
|
||||||
.map((key) => byKey.get(key))
|
|
||||||
.filter((field): field is FormField => field !== undefined)
|
|
||||||
return fields.length === 0
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
title: section.title,
|
|
||||||
icon: section.icon,
|
|
||||||
openByDefault: section.openByDefault,
|
|
||||||
fields,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
const others = allFormFields.value.filter((field) => !knownKeys.has(field.key))
|
|
||||||
if (others.length > 0) {
|
|
||||||
sections.push({
|
|
||||||
title: sectionMessages.others,
|
|
||||||
icon: SECTION_ICONS.others,
|
|
||||||
openByDefault: false,
|
|
||||||
fields: others,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return sections
|
|
||||||
})
|
|
||||||
|
|
||||||
function setFieldValue(key: string, value: string | number | undefined) {
|
|
||||||
entries.value = setProperty(entries.value, key, value?.toString() ?? '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchMode(next: 'form' | 'text') {
|
|
||||||
if (next === 'text' && mode.value === 'form') {
|
|
||||||
rawText.value = serializeProperties(entries.value)
|
|
||||||
} else if (next === 'form' && mode.value === 'text') {
|
|
||||||
entries.value = parseProperties(rawText.value)
|
|
||||||
}
|
|
||||||
mode.value = next
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(): Promise<boolean> {
|
|
||||||
if (isMissing.value) return true
|
|
||||||
isSaving.value = true
|
|
||||||
try {
|
|
||||||
const text = mode.value === 'text' ? rawText.value : serializeProperties(entries.value)
|
|
||||||
await servers.writeFile(props.serverId, FILE_NAME, text)
|
|
||||||
entries.value = parseProperties(text)
|
|
||||||
rawText.value = text
|
|
||||||
baselineText.value = text
|
|
||||||
normalizedBaseline.value = serializeProperties(entries.value)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
handleError?.(error)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
entries.value = parseProperties(baselineText.value)
|
|
||||||
rawText.value = baselineText.value
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ save, cancel, isDirty })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section data-onboarding-id="server-properties" class="flex flex-col gap-4">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="flex min-w-0 items-center gap-2.5">
|
|
||||||
<div
|
|
||||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-surface-3 text-contrast"
|
|
||||||
>
|
|
||||||
<FileTextIcon class="size-4" />
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h3 class="m-0 truncate text-base font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.title) }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ButtonStyled :type="mode === 'form' ? 'highlight' : 'transparent'" size="small">
|
|
||||||
<button type="button" @click="switchMode('form')">
|
|
||||||
{{ formatMessage(messages.formMode) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled :type="mode === 'text' ? 'highlight' : 'transparent'" size="small">
|
|
||||||
<button type="button" @click="switchMode('text')">
|
|
||||||
{{ formatMessage(messages.textMode) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="isMissing" class="m-0 text-secondary">
|
|
||||||
{{ formatMessage(messages.missing) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<template v-else-if="mode === 'form'">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<Accordion
|
|
||||||
v-for="section in formSections"
|
|
||||||
:key="section.title.id"
|
|
||||||
:open-by-default="section.openByDefault"
|
|
||||||
overflow-visible
|
|
||||||
:button-class="'group flex min-h-11 w-full cursor-pointer items-center gap-3 bg-transparent px-1 text-left'"
|
|
||||||
class="border-0 border-b border-solid border-surface-4 py-1 last:border-b-0"
|
|
||||||
>
|
|
||||||
<template #button="{ open }">
|
|
||||||
<span class="flex min-w-0 flex-1 items-center gap-3">
|
|
||||||
<span
|
|
||||||
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-surface-3 text-secondary transition-colors group-hover:text-primary"
|
|
||||||
>
|
|
||||||
<component :is="section.icon" class="size-4" />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="min-w-0 flex-1 truncate text-sm font-semibold text-primary group-hover:text-contrast"
|
|
||||||
>
|
|
||||||
{{ formatMessage(section.title) }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<DropdownIcon
|
|
||||||
class="ml-auto size-4 shrink-0 text-secondary transition-transform duration-300 group-hover:text-primary"
|
|
||||||
:class="open && 'rotate-180'"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-1 gap-x-5 gap-y-3 px-1 pb-4 pt-1 sm:grid-cols-2 xl:grid-cols-3"
|
|
||||||
>
|
|
||||||
<template v-for="item in section.fields" :key="item.key">
|
|
||||||
<div
|
|
||||||
v-if="item.field.kind === 'boolean'"
|
|
||||||
class="flex min-h-9 min-w-0 items-center justify-between gap-3"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="truncate text-sm font-medium text-primary"
|
|
||||||
:for="`server-prop-${item.key}`"
|
|
||||||
>
|
|
||||||
<span v-tooltip="item.key">{{ fieldLabel(item.key) }}</span>
|
|
||||||
</label>
|
|
||||||
<Toggle
|
|
||||||
:id="`server-prop-${item.key}`"
|
|
||||||
:model-value="item.value === 'true'"
|
|
||||||
small
|
|
||||||
@update:model-value="setFieldValue(item.key, $event ? 'true' : 'false')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex min-w-0 flex-col gap-1.5">
|
|
||||||
<label
|
|
||||||
class="truncate text-sm font-medium text-primary"
|
|
||||||
:for="`server-prop-${item.key}`"
|
|
||||||
>
|
|
||||||
<span v-tooltip="item.key">{{ fieldLabel(item.key) }}</span>
|
|
||||||
</label>
|
|
||||||
<StyledInput
|
|
||||||
v-if="item.field.kind === 'integer' || item.field.kind === 'number'"
|
|
||||||
:id="`server-prop-${item.key}`"
|
|
||||||
:model-value="item.value"
|
|
||||||
inputmode="numeric"
|
|
||||||
size="small"
|
|
||||||
wrapper-class="w-full"
|
|
||||||
@update:model-value="setFieldValue(item.key, $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DropdownSelect
|
|
||||||
v-else-if="item.field.kind === 'enum'"
|
|
||||||
:model-value="item.value"
|
|
||||||
:options="item.field.options ?? []"
|
|
||||||
:name="`server-prop-${item.key}`"
|
|
||||||
class="!w-full"
|
|
||||||
@update:model-value="setFieldValue(item.key, $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StyledInput
|
|
||||||
v-else
|
|
||||||
:id="`server-prop-${item.key}`"
|
|
||||||
:model-value="item.value"
|
|
||||||
size="small"
|
|
||||||
wrapper-class="w-full"
|
|
||||||
@update:model-value="setFieldValue(item.key, $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="h-[clamp(18rem,60vh,32rem)] overflow-hidden rounded-lg border border-solid border-surface-4"
|
|
||||||
>
|
|
||||||
<StudioEditor
|
|
||||||
file-path="server.properties"
|
|
||||||
language="properties"
|
|
||||||
:content="rawText"
|
|
||||||
:read-only="isSaving"
|
|
||||||
@update:content="rawText = $event"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ImageIcon, SaveIcon, SpinnerIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
|
||||||
import { requiredJavaMajorVersion } from '@modrinth/server'
|
|
||||||
import {
|
|
||||||
Admonition,
|
|
||||||
ButtonStyled,
|
|
||||||
Card,
|
|
||||||
ConfirmModal,
|
|
||||||
defineMessages,
|
|
||||||
injectFilePicker,
|
|
||||||
injectNotificationManager,
|
|
||||||
StyledInput,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
|
|
||||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
||||||
|
|
||||||
import ServerIcon from '@/components/multiplayer/servers/ServerIcon.vue'
|
|
||||||
import ServerPropertiesEditor from '@/components/multiplayer/servers/ServerPropertiesEditor.vue'
|
|
||||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
|
||||||
import { type ServerView, useServers } from '@/composables/useServers'
|
|
||||||
import { get_jre } from '@/helpers/jre'
|
|
||||||
import { servers as serversApi } from '@/helpers/servers'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
server: ServerView
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
deleted: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const messages = defineMessages({
|
|
||||||
general: { id: 'app.servers.settings.general', defaultMessage: 'General' },
|
|
||||||
name: { id: 'app.servers.settings.name', defaultMessage: 'Server name' },
|
|
||||||
icon: { id: 'app.servers.settings.icon', defaultMessage: 'Icon' },
|
|
||||||
selectIcon: { id: 'app.servers.icon.select', defaultMessage: 'Select icon' },
|
|
||||||
changeIcon: { id: 'app.servers.icon.change', defaultMessage: 'Change icon' },
|
|
||||||
removeIcon: { id: 'app.servers.icon.remove', defaultMessage: 'Remove icon' },
|
|
||||||
java: { id: 'app.servers.settings.java', defaultMessage: 'Java' },
|
|
||||||
memory: { id: 'app.servers.settings.memory', defaultMessage: 'Memory (MB)' },
|
|
||||||
jvmArgs: { id: 'app.servers.settings.jvm-args', defaultMessage: 'JVM arguments' },
|
|
||||||
jvmArgsHint: {
|
|
||||||
id: 'app.servers.settings.jvm-args-hint',
|
|
||||||
defaultMessage: 'Space-separated arguments, e.g. -XX:+UseG1GC',
|
|
||||||
},
|
|
||||||
save: { id: 'app.servers.settings.save', defaultMessage: 'Save changes' },
|
|
||||||
saved: { id: 'app.servers.settings.saved', defaultMessage: 'Server settings saved' },
|
|
||||||
cancel: { id: 'app.servers.settings.cancel', defaultMessage: 'Cancel' },
|
|
||||||
deleteTitle: { id: 'app.servers.settings.delete', defaultMessage: 'Delete server' },
|
|
||||||
deleteHint: {
|
|
||||||
id: 'app.servers.settings.delete-hint',
|
|
||||||
defaultMessage: 'Permanently remove this server and all of its files.',
|
|
||||||
},
|
|
||||||
deleteConfirm: {
|
|
||||||
id: 'app.servers.settings.delete-confirm',
|
|
||||||
defaultMessage: 'Delete {name} and all of its files? This cannot be undone.',
|
|
||||||
},
|
|
||||||
deleteProceed: { id: 'app.servers.settings.delete-proceed', defaultMessage: 'Delete' },
|
|
||||||
configFiles: { id: 'app.servers.settings.config', defaultMessage: 'Configuration' },
|
|
||||||
runningTitle: {
|
|
||||||
id: 'app.servers.settings.running-title',
|
|
||||||
defaultMessage: 'Server is running',
|
|
||||||
},
|
|
||||||
runningHint: {
|
|
||||||
id: 'app.servers.settings.running-hint',
|
|
||||||
defaultMessage: 'Your changes will take effect the next time the server starts.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { deleteServer, refresh } = useServers()
|
|
||||||
const { addNotification, handleError } = injectNotificationManager()
|
|
||||||
const filePicker = injectFilePicker()
|
|
||||||
|
|
||||||
const name = ref(props.server.name)
|
|
||||||
const iconPath = ref<string | null>(props.server.iconPath ?? null)
|
|
||||||
const javaSelection = ref<{ path: string; version: string }>({
|
|
||||||
path: props.server.javaPath ?? '',
|
|
||||||
version: '',
|
|
||||||
})
|
|
||||||
const memoryMb = ref(props.server.memoryMb ?? 2048)
|
|
||||||
const jvmArgsText = ref((props.server.jvmArgs ?? []).join(' '))
|
|
||||||
const isSaving = ref(false)
|
|
||||||
const deleteModal = useTemplateRef<ComponentExposed<typeof ConfirmModal>>('deleteModal')
|
|
||||||
const editor = useTemplateRef<ComponentExposed<typeof ServerPropertiesEditor>>('editor')
|
|
||||||
|
|
||||||
const requiredJava = computed(() => requiredJavaMajorVersion(props.server.gameVersion))
|
|
||||||
|
|
||||||
// Same busy boundary as the files panel: a running server holds its
|
|
||||||
// configuration in memory and may overwrite external edits, and
|
|
||||||
// manifest changes only take effect after a restart anyway.
|
|
||||||
const isRunning = computed(() => props.server.running)
|
|
||||||
|
|
||||||
const baseline = ref({
|
|
||||||
name: props.server.name,
|
|
||||||
iconPath: props.server.iconPath ?? null,
|
|
||||||
javaPath: props.server.javaPath ?? '',
|
|
||||||
javaVersion: '',
|
|
||||||
memoryMb: props.server.memoryMb ?? 2048,
|
|
||||||
jvmArgs: (props.server.jvmArgs ?? []).join(' '),
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!javaSelection.value.path) return
|
|
||||||
try {
|
|
||||||
const jre = await get_jre(javaSelection.value.path)
|
|
||||||
if (jre) {
|
|
||||||
javaSelection.value.version = jre.version
|
|
||||||
baseline.value.javaVersion = jre.version
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep the path; the selector validates against the required major version.
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.server.iconPath,
|
|
||||||
(value) => {
|
|
||||||
const synced = value ?? null
|
|
||||||
iconPath.value = synced
|
|
||||||
baseline.value.iconPath = synced
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const generalDirty = computed(
|
|
||||||
() =>
|
|
||||||
name.value !== baseline.value.name ||
|
|
||||||
iconPath.value !== baseline.value.iconPath ||
|
|
||||||
javaSelection.value.path !== baseline.value.javaPath ||
|
|
||||||
javaSelection.value.version !== baseline.value.javaVersion ||
|
|
||||||
memoryMb.value !== baseline.value.memoryMb ||
|
|
||||||
jvmArgsText.value !== baseline.value.jvmArgs,
|
|
||||||
)
|
|
||||||
|
|
||||||
const isDirty = computed(() => generalDirty.value || (editor.value?.isDirty ?? false))
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
isSaving.value = true
|
|
||||||
try {
|
|
||||||
const jvmArgs = jvmArgsText.value.trim().split(/\s+/).filter(Boolean)
|
|
||||||
const parsedMemory = Number(memoryMb.value)
|
|
||||||
const memoryMbValue =
|
|
||||||
Number.isFinite(parsedMemory) && parsedMemory > 0 ? parsedMemory : baseline.value.memoryMb
|
|
||||||
await serversApi.updateSettings(props.server.id, {
|
|
||||||
name: name.value.trim(),
|
|
||||||
javaPath: javaSelection.value.path,
|
|
||||||
memoryMb: memoryMbValue,
|
|
||||||
jvmArgs,
|
|
||||||
})
|
|
||||||
if (iconPath.value !== baseline.value.iconPath) {
|
|
||||||
await serversApi.setIcon(props.server.id, iconPath.value)
|
|
||||||
}
|
|
||||||
const propsSaved = (await editor.value?.save()) ?? true
|
|
||||||
if (!propsSaved) return
|
|
||||||
name.value = name.value.trim()
|
|
||||||
memoryMb.value = memoryMbValue
|
|
||||||
jvmArgsText.value = jvmArgs.join(' ')
|
|
||||||
baseline.value = {
|
|
||||||
name: name.value,
|
|
||||||
iconPath: iconPath.value,
|
|
||||||
javaPath: javaSelection.value.path,
|
|
||||||
javaVersion: javaSelection.value.version,
|
|
||||||
memoryMb: memoryMbValue,
|
|
||||||
jvmArgs: jvmArgsText.value,
|
|
||||||
}
|
|
||||||
await refresh()
|
|
||||||
addNotification({ type: 'success', title: formatMessage(messages.saved) })
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error)
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
name.value = baseline.value.name
|
|
||||||
iconPath.value = baseline.value.iconPath
|
|
||||||
javaSelection.value = { path: baseline.value.javaPath, version: baseline.value.javaVersion }
|
|
||||||
memoryMb.value = baseline.value.memoryMb
|
|
||||||
jvmArgsText.value = baseline.value.jvmArgs
|
|
||||||
editor.value?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickIcon() {
|
|
||||||
try {
|
|
||||||
const picked = await (filePicker.pickInstanceIcon?.() ?? filePicker.pickImage())
|
|
||||||
if (picked?.path) iconPath.value = picked.path
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDelete() {
|
|
||||||
const ok = await deleteServer(props.server.id)
|
|
||||||
if (ok) emit('deleted')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex min-h-full flex-col">
|
|
||||||
<div class="flex flex-col gap-6 pb-20">
|
|
||||||
<Admonition v-if="isRunning" type="warning" :header="formatMessage(messages.runningTitle)">
|
|
||||||
{{ formatMessage(messages.runningHint) }}
|
|
||||||
</Admonition>
|
|
||||||
|
|
||||||
<Card data-onboarding-id="server-settings" class="!m-0">
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<h3 class="m-0 col-span-full text-base font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.general) }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 items-center gap-3 sm:col-span-2 xl:col-span-4">
|
|
||||||
<ServerIcon
|
|
||||||
:icon-path="iconPath"
|
|
||||||
:server-type="server.serverType"
|
|
||||||
:server-id="server.id"
|
|
||||||
size="48px"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.icon) }}</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<ButtonStyled type="outlined" size="small">
|
|
||||||
<button type="button" @click="pickIcon">
|
|
||||||
<ImageIcon />
|
|
||||||
{{
|
|
||||||
iconPath
|
|
||||||
? formatMessage(messages.changeIcon)
|
|
||||||
: formatMessage(messages.selectIcon)
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-if="iconPath" color="red" type="outlined" size="small">
|
|
||||||
<button type="button" @click="iconPath = null">
|
|
||||||
<TrashIcon />
|
|
||||||
{{ formatMessage(messages.removeIcon) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex min-w-0 flex-col gap-2" for="server-settings-name">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
|
|
||||||
<StyledInput id="server-settings-name" v-model="name" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex min-w-0 flex-col gap-2" for="server-settings-memory">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.memory) }}</span>
|
|
||||||
<StyledInput
|
|
||||||
id="server-settings-memory"
|
|
||||||
v-model="memoryMb"
|
|
||||||
inputmode="numeric"
|
|
||||||
wrapper-class="max-w-40"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-2 sm:col-span-2 xl:col-span-2">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.java) }}</span>
|
|
||||||
<JavaSelector
|
|
||||||
id="server-settings-java"
|
|
||||||
v-model="javaSelection"
|
|
||||||
:version="requiredJava"
|
|
||||||
select-all-versions
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label
|
|
||||||
class="flex min-w-0 flex-col gap-2 sm:col-span-2 xl:col-span-4"
|
|
||||||
for="server-settings-jvm"
|
|
||||||
>
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.jvmArgs) }}</span>
|
|
||||||
<StyledInput id="server-settings-jvm" v-model="jvmArgsText" />
|
|
||||||
<span class="text-xs text-secondary">{{ formatMessage(messages.jvmArgsHint) }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="!m-0">
|
|
||||||
<ServerPropertiesEditor ref="editor" :server-id="server.id" />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="!m-0">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
|
||||||
<div
|
|
||||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-highlight text-red"
|
|
||||||
>
|
|
||||||
<TrashIcon class="size-4" />
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.deleteTitle) }}
|
|
||||||
</h3>
|
|
||||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
|
||||||
{{ formatMessage(messages.deleteHint) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ButtonStyled color="red" type="outlined">
|
|
||||||
<button type="button" :disabled="server.running" @click="deleteModal?.show()">
|
|
||||||
<TrashIcon />
|
|
||||||
{{ formatMessage(messages.deleteTitle) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isDirty"
|
|
||||||
class="fixed bottom-4 z-50 flex"
|
|
||||||
:style="{
|
|
||||||
left: 'calc(var(--left-bar-width) + 1.5rem)',
|
|
||||||
width: 'calc(100% - var(--left-bar-width) - var(--right-bar-width) - 3rem)',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="flex w-full items-center justify-end">
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 rounded-xl border border-solid border-button-border bg-bg-raised px-3 py-2 shadow-lg"
|
|
||||||
>
|
|
||||||
<ButtonStyled type="outlined">
|
|
||||||
<button type="button" :disabled="isSaving" @click="cancel">
|
|
||||||
<XIcon />
|
|
||||||
{{ formatMessage(messages.cancel) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button type="button" :disabled="isSaving || isRunning" @click="save">
|
|
||||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
|
||||||
<SaveIcon v-else />
|
|
||||||
{{ formatMessage(messages.save) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
ref="deleteModal"
|
|
||||||
:title="formatMessage(messages.deleteTitle)"
|
|
||||||
:description="formatMessage(messages.deleteConfirm, { name: server.name })"
|
|
||||||
:proceed-label="formatMessage(messages.deleteProceed)"
|
|
||||||
@proceed="confirmDelete"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
CollectionIcon,
|
|
||||||
GridIcon,
|
|
||||||
PlusIcon,
|
|
||||||
RefreshCwIcon,
|
|
||||||
ServerIcon,
|
|
||||||
SpinnerIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import { ButtonStyled, defineMessages, EmptyState, PopoutMenu, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed, onMounted, ref, useTemplateRef } from 'vue'
|
|
||||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import CreateServerModal from '@/components/multiplayer/servers/CreateServerModal.vue'
|
|
||||||
import EulaModal from '@/components/multiplayer/servers/EulaModal.vue'
|
|
||||||
import ServerCard from '@/components/multiplayer/servers/ServerCard.vue'
|
|
||||||
import { useServerLifecycle } from '@/composables/useServerLifecycle'
|
|
||||||
import { type ServerView, useServers } from '@/composables/useServers'
|
|
||||||
import {
|
|
||||||
getLastLibraryDisplayMode,
|
|
||||||
setLastLibraryDisplayMode,
|
|
||||||
} from '@/helpers/library-display-mode'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const { servers, isRefreshing, refresh, stopServer } = useServers()
|
|
||||||
const { eulaModal, eulaText, tryStartServer, acceptEula, declineEula, resumeInstall } =
|
|
||||||
useServerLifecycle()
|
|
||||||
const createModal = useTemplateRef<ComponentExposed<typeof CreateServerModal>>('createModal')
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
create: { id: 'app.servers.create.title', defaultMessage: 'Create server' },
|
|
||||||
refresh: { id: 'app.servers.refresh', defaultMessage: 'Refresh' },
|
|
||||||
emptyHeading: {
|
|
||||||
id: 'app.servers.empty.heading',
|
|
||||||
defaultMessage: 'No servers yet',
|
|
||||||
},
|
|
||||||
emptyDescription: {
|
|
||||||
id: 'app.servers.empty.description',
|
|
||||||
defaultMessage: 'Create a server to play with friends, right from the launcher.',
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
id: 'app.servers.count',
|
|
||||||
defaultMessage: '{count, plural, =0 {No servers yet} one {# server} other {# servers}}',
|
|
||||||
},
|
|
||||||
loading: { id: 'app.servers.loading', defaultMessage: 'Loading servers...' },
|
|
||||||
view: { id: 'app.library.view', defaultMessage: 'View' },
|
|
||||||
standardView: { id: 'app.library.view.standard', defaultMessage: 'Standard grid' },
|
|
||||||
cardsView: { id: 'app.library.view.cards', defaultMessage: 'Library cards' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayMode = ref(getLastLibraryDisplayMode())
|
|
||||||
const displayModeOptions = computed(() => [
|
|
||||||
{ id: 'standard' as const, label: formatMessage(messages.standardView), icon: GridIcon },
|
|
||||||
{ id: 'cards' as const, label: formatMessage(messages.cardsView), icon: CollectionIcon },
|
|
||||||
])
|
|
||||||
const currentDisplayMode = computed(() =>
|
|
||||||
displayModeOptions.value.find((option) => option.id === displayMode.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void refresh()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function openServer(id: string) {
|
|
||||||
// Refresh first so the freshly created server is present in the shared store
|
|
||||||
// before ServerDetail mounts; otherwise it briefly shows "server not found".
|
|
||||||
await refresh().catch(() => {})
|
|
||||||
void router.push('/multiplayer/servers/' + encodeURIComponent(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDisplayMode(mode: 'standard' | 'cards') {
|
|
||||||
displayMode.value = mode
|
|
||||||
setLastLibraryDisplayMode(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleRunning(server: ServerView) {
|
|
||||||
if (server.status === 'running') {
|
|
||||||
await stopServer(server.id)
|
|
||||||
} else {
|
|
||||||
await tryStartServer(server)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div data-onboarding-id="servers-overview" class="flex min-h-0 w-full flex-1 flex-col gap-4">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<span class="flex items-center gap-2 text-sm text-secondary">
|
|
||||||
<SpinnerIcon v-if="isRefreshing" class="size-4 animate-spin" />
|
|
||||||
<ServerIcon v-else class="size-4" />
|
|
||||||
{{
|
|
||||||
isRefreshing
|
|
||||||
? formatMessage(messages.loading)
|
|
||||||
: formatMessage(messages.count, { count: servers.length })
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<PopoutMenu :tooltip="formatMessage(messages.view)" placement="bottom-end">
|
|
||||||
<ButtonStyled circular>
|
|
||||||
<button type="button" :aria-label="formatMessage(messages.view)">
|
|
||||||
<component :is="currentDisplayMode?.icon" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<template #menu>
|
|
||||||
<div class="flex w-44 flex-col gap-1 p-1">
|
|
||||||
<ButtonStyled
|
|
||||||
v-for="option in displayModeOptions"
|
|
||||||
:key="option.id"
|
|
||||||
:type="displayMode === option.id ? 'filled' : 'transparent'"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center gap-2 !justify-start text-left"
|
|
||||||
:aria-pressed="displayMode === option.id"
|
|
||||||
@click="setDisplayMode(option.id)"
|
|
||||||
>
|
|
||||||
<component :is="option.icon" class="size-4" />
|
|
||||||
{{ option.label }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</PopoutMenu>
|
|
||||||
<ButtonStyled type="outlined">
|
|
||||||
<button type="button" :disabled="isRefreshing" @click="refresh()">
|
|
||||||
<RefreshCwIcon :class="{ 'animate-spin': isRefreshing }" />
|
|
||||||
{{ formatMessage(messages.refresh) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-onboarding-id="create-server-button"
|
|
||||||
@click="createModal?.show()"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
{{ formatMessage(messages.create) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EmptyState
|
|
||||||
v-if="servers.length === 0 && !isRefreshing"
|
|
||||||
type="empty"
|
|
||||||
:heading="formatMessage(messages.emptyHeading)"
|
|
||||||
:description="formatMessage(messages.emptyDescription)"
|
|
||||||
>
|
|
||||||
<ButtonStyled color="brand" size="large">
|
|
||||||
<button type="button" @click="createModal?.show()">
|
|
||||||
<ServerIcon />
|
|
||||||
{{ formatMessage(messages.create) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</EmptyState>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] w-full max-w-[72rem] gap-3"
|
|
||||||
:class="{
|
|
||||||
'grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-4': displayMode === 'cards',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ServerCard
|
|
||||||
v-for="entry in servers"
|
|
||||||
:key="entry.id"
|
|
||||||
:server="entry"
|
|
||||||
:variant="displayMode === 'cards' ? 'library' : 'standard'"
|
|
||||||
@open="openServer(entry.id)"
|
|
||||||
@start-stop="toggleRunning(entry)"
|
|
||||||
@resume="resumeInstall(entry)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CreateServerModal ref="createModal" @created="openServer" />
|
|
||||||
<EulaModal ref="eulaModal" :text="eulaText" @continue="acceptEula" @decline="declineEula" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
@ -1,466 +0,0 @@
|
|||||||
import { RefreshCwIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
isServerTypeSupported,
|
|
||||||
requiredJavaMajorVersion,
|
|
||||||
SERVER_TYPES,
|
|
||||||
type ServerTypeId,
|
|
||||||
setEulaAccepted,
|
|
||||||
} from '@modrinth/server'
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
defineMessages,
|
|
||||||
type MultiStageModal,
|
|
||||||
type StageConfigInput,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { computed, markRaw, type Ref, ref } from 'vue'
|
|
||||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
||||||
|
|
||||||
import {
|
|
||||||
javaMajorFromVersion,
|
|
||||||
toErrorMessage,
|
|
||||||
} from '@/components/multiplayer/servers/server-flow-utils'
|
|
||||||
import { getServerInstallStrategy, runServerInstall } from '@/composables/server-install'
|
|
||||||
import { refresh as refreshServerList } from '@/composables/useServers'
|
|
||||||
import { find_filtered_jres, get_java_default_versions, get_max_memory } from '@/helpers/jre'
|
|
||||||
import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
|
|
||||||
import { type ServerManifestData, servers } from '@/helpers/servers'
|
|
||||||
import { injectDownloadManager } from '@/providers/download-manager'
|
|
||||||
|
|
||||||
import InstallStage from './stages/InstallStage.vue'
|
|
||||||
import SetupStage from './stages/SetupStage.vue'
|
|
||||||
import TypeStage from './stages/TypeStage.vue'
|
|
||||||
|
|
||||||
export type InstallPhase =
|
|
||||||
| 'idle'
|
|
||||||
| 'preparing'
|
|
||||||
| 'downloading'
|
|
||||||
| 'first-run'
|
|
||||||
| 'eula'
|
|
||||||
| 'error'
|
|
||||||
| 'done'
|
|
||||||
|
|
||||||
export interface JavaSelection {
|
|
||||||
path: string
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoaderVersionOption {
|
|
||||||
id: string
|
|
||||||
stable: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateServerFlowContext<TCtx extends CreateServerFlowContext<TCtx>> {
|
|
||||||
modal: Ref<ComponentExposed<typeof MultiStageModal> | null>
|
|
||||||
stageConfigs: StageConfigInput<TCtx>[]
|
|
||||||
formatMessage: ReturnType<typeof useVIntl>['formatMessage']
|
|
||||||
|
|
||||||
serverType: Ref<ServerTypeId>
|
|
||||||
availableGameVersions: Ref<string[]>
|
|
||||||
selectedGameVersion: Ref<string>
|
|
||||||
showSnapshots: Ref<boolean>
|
|
||||||
loaderVersions: Ref<LoaderVersionOption[]>
|
|
||||||
selectedLoaderVersion: Ref<string>
|
|
||||||
isVersionsLoading: Ref<boolean>
|
|
||||||
versionsError: Ref<string | null>
|
|
||||||
|
|
||||||
name: Ref<string>
|
|
||||||
selectedJava: Ref<JavaSelection>
|
|
||||||
memoryMb: Ref<number>
|
|
||||||
maxMemoryMb: Ref<number>
|
|
||||||
|
|
||||||
installPhase: Ref<InstallPhase>
|
|
||||||
downloadProgress: Ref<{ downloaded: number; total: number | null } | null>
|
|
||||||
installLog: Ref<string[]>
|
|
||||||
installError: Ref<string | null>
|
|
||||||
eulaText: Ref<string>
|
|
||||||
createdServer: Ref<ServerManifestData | null>
|
|
||||||
showEulaModal: Ref<boolean>
|
|
||||||
|
|
||||||
/** Registered by the configure stage to persist server.properties before finishing. */
|
|
||||||
saveServerProperties: Ref<(() => Promise<boolean>) | null>
|
|
||||||
|
|
||||||
needsLoaderVersion: Ref<boolean>
|
|
||||||
typeSupported: Ref<boolean>
|
|
||||||
canContinueFromType: Ref<boolean>
|
|
||||||
|
|
||||||
loadVersions: () => Promise<void>
|
|
||||||
loadLoaderVersions: () => Promise<void>
|
|
||||||
loadDefaultJava: () => Promise<void>
|
|
||||||
beginInstall: () => Promise<void>
|
|
||||||
retryInstall: () => Promise<void>
|
|
||||||
acceptEula: () => Promise<void>
|
|
||||||
declineEula: () => void
|
|
||||||
reset: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Concrete context used by the vanilla (non-modpack) server creation flow. */
|
|
||||||
export type CreateServerFlowContextValue = CreateServerFlowContext<CreateServerFlowContextValue>
|
|
||||||
|
|
||||||
export const [injectCreateServerFlow, provideCreateServerFlow] =
|
|
||||||
createContext<CreateServerFlowContextValue>('CreateServerFlow')
|
|
||||||
|
|
||||||
export function createCreateServerFlowContext(
|
|
||||||
modal: Ref<ComponentExposed<typeof MultiStageModal> | null>,
|
|
||||||
): CreateServerFlowContextValue {
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
|
|
||||||
// [SERVER-DOWNLOAD-BRIDGE] Capture the download manager once during Vue
|
|
||||||
// setup context. Vue's inject() only works in the synchronous setup
|
|
||||||
// scope — after any `await` the injection context is lost. We store the
|
|
||||||
// reference here and pass it explicitly to the shared download bridge so
|
|
||||||
// the vanilla server download appears in the sidebar like the modpack flow.
|
|
||||||
let downloadManager: ReturnType<typeof injectDownloadManager> | null = null
|
|
||||||
try {
|
|
||||||
downloadManager = injectDownloadManager()
|
|
||||||
} catch {
|
|
||||||
// Not inside a provider tree — server downloads will not appear in sidebar.
|
|
||||||
}
|
|
||||||
|
|
||||||
const wizardMessages = defineMessages({
|
|
||||||
typeStageTitle: { id: 'app.servers.wizard.type-title', defaultMessage: 'Server type' },
|
|
||||||
setupStageTitle: { id: 'app.servers.wizard.setup-title', defaultMessage: 'Setup' },
|
|
||||||
installStageTitle: { id: 'app.servers.wizard.install-title', defaultMessage: 'Install' },
|
|
||||||
configureStageTitle: { id: 'app.servers.wizard.configure-title', defaultMessage: 'Configure' },
|
|
||||||
next: { id: 'app.servers.wizard.next', defaultMessage: 'Next' },
|
|
||||||
retry: { id: 'app.servers.wizard.retry', defaultMessage: 'Retry' },
|
|
||||||
finish: { id: 'app.servers.wizard.finish', defaultMessage: 'Finish' },
|
|
||||||
javaTooOld: {
|
|
||||||
id: 'app.servers.wizard.java-too-old',
|
|
||||||
defaultMessage:
|
|
||||||
'Java {selected} cannot run this game version; Java {required} or newer is required.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverType = ref<ServerTypeId>('vanilla')
|
|
||||||
const availableGameVersions = ref<string[]>([])
|
|
||||||
const selectedGameVersion = ref('')
|
|
||||||
const showSnapshots = ref(false)
|
|
||||||
const loaderVersions = ref<LoaderVersionOption[]>([])
|
|
||||||
const selectedLoaderVersion = ref('')
|
|
||||||
const isVersionsLoading = ref(false)
|
|
||||||
const versionsError = ref<string | null>(null)
|
|
||||||
|
|
||||||
const name = ref('')
|
|
||||||
const selectedJava = ref<JavaSelection>({ path: '', version: '' })
|
|
||||||
const memoryMb = ref(2048)
|
|
||||||
const maxMemoryMb = ref(8192)
|
|
||||||
|
|
||||||
const installPhase = ref<InstallPhase>('idle')
|
|
||||||
const downloadProgress = ref<{ downloaded: number; total: number | null } | null>(null)
|
|
||||||
const installLog = ref<string[]>([])
|
|
||||||
const installError = ref<string | null>(null)
|
|
||||||
const eulaText = ref('')
|
|
||||||
const createdServer = ref<ServerManifestData | null>(null)
|
|
||||||
const showEulaModal = ref(false)
|
|
||||||
const saveServerProperties = ref<(() => Promise<boolean>) | null>(null)
|
|
||||||
|
|
||||||
const needsLoaderVersion = computed(
|
|
||||||
() => SERVER_TYPES[serverType.value]?.needsLoaderVersion ?? false,
|
|
||||||
)
|
|
||||||
const typeSupported = computed(() => isServerTypeSupported(serverType.value))
|
|
||||||
|
|
||||||
async function loadVersions() {
|
|
||||||
isVersionsLoading.value = true
|
|
||||||
versionsError.value = null
|
|
||||||
try {
|
|
||||||
const manifest = (await get_game_versions()) as {
|
|
||||||
latest: { release: string }
|
|
||||||
versions: { id: string; type: string; url: string }[]
|
|
||||||
}
|
|
||||||
const all = manifest.versions
|
|
||||||
availableGameVersions.value = all
|
|
||||||
.filter((entry) => (showSnapshots.value ? true : entry.type === 'release'))
|
|
||||||
.map((entry) => entry.id)
|
|
||||||
if (!availableGameVersions.value.includes(selectedGameVersion.value)) {
|
|
||||||
selectedGameVersion.value =
|
|
||||||
manifest.latest.release && availableGameVersions.value.includes(manifest.latest.release)
|
|
||||||
? manifest.latest.release
|
|
||||||
: availableGameVersions.value[0]
|
|
||||||
}
|
|
||||||
await loadLoaderVersions()
|
|
||||||
} catch (error) {
|
|
||||||
versionsError.value = toErrorMessage(error)
|
|
||||||
} finally {
|
|
||||||
isVersionsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLoaderVersions() {
|
|
||||||
selectedLoaderVersion.value = ''
|
|
||||||
loaderVersions.value = []
|
|
||||||
if (serverType.value !== 'fabric' || !selectedGameVersion.value) return
|
|
||||||
try {
|
|
||||||
const manifest = (await get_loader_versions('fabric', selectedGameVersion.value)) as {
|
|
||||||
gameVersions: Array<{ id: string; loaders: LoaderVersionOption[] }>
|
|
||||||
}
|
|
||||||
const entry = manifest.gameVersions.find((game) => game.id === selectedGameVersion.value)
|
|
||||||
loaderVersions.value = entry?.loaders ?? []
|
|
||||||
selectedLoaderVersion.value = loaderVersions.value[0]?.id ?? ''
|
|
||||||
} catch {
|
|
||||||
loaderVersions.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prefills the Java path from the instance-level defaults, falling back to a scan. */
|
|
||||||
async function loadDefaultJava() {
|
|
||||||
if (selectedJava.value.path !== '') return
|
|
||||||
const major = requiredJavaMajorVersion(selectedGameVersion.value || '1.21')
|
|
||||||
try {
|
|
||||||
const defaults = (await get_java_default_versions()) as Array<{
|
|
||||||
parsed_version: number
|
|
||||||
version: string
|
|
||||||
path: string
|
|
||||||
}>
|
|
||||||
const match =
|
|
||||||
defaults.find((entry) => entry.parsed_version === major) ??
|
|
||||||
defaults.find((entry) => entry.parsed_version >= major)
|
|
||||||
if (match) {
|
|
||||||
selectedJava.value = { path: match.path, version: match.version }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to a filtered scan
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const javas = (await find_filtered_jres(major)) as JavaSelection[]
|
|
||||||
if (javas.length > 0) selectedJava.value = javas[0]
|
|
||||||
} catch {
|
|
||||||
// Leave empty; the user picks manually in the setup stage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMaxMemory() {
|
|
||||||
try {
|
|
||||||
const maxKiB = (await get_max_memory()) as number
|
|
||||||
maxMemoryMb.value = Math.max(1024, Math.floor(maxKiB / 1024))
|
|
||||||
} catch {
|
|
||||||
maxMemoryMb.value = 8192
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function beginInstall() {
|
|
||||||
if (installPhase.value === 'downloading' || installPhase.value === 'first-run') return
|
|
||||||
installPhase.value = 'preparing'
|
|
||||||
installError.value = null
|
|
||||||
installLog.value = []
|
|
||||||
downloadProgress.value = null
|
|
||||||
try {
|
|
||||||
const requiredJava = requiredJavaMajorVersion(selectedGameVersion.value)
|
|
||||||
const selectedMajor = javaMajorFromVersion(selectedJava.value.version)
|
|
||||||
if (
|
|
||||||
selectedJava.value.path !== '' &&
|
|
||||||
selectedMajor !== null &&
|
|
||||||
selectedMajor < requiredJava
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
formatMessage(wizardMessages.javaTooOld, {
|
|
||||||
selected: selectedMajor,
|
|
||||||
required: requiredJava,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const manifest = await servers.create({
|
|
||||||
name: name.value,
|
|
||||||
serverType: serverType.value,
|
|
||||||
gameVersion: selectedGameVersion.value,
|
|
||||||
loaderVersion: serverType.value === 'fabric' ? selectedLoaderVersion.value : undefined,
|
|
||||||
javaPath: selectedJava.value.path || undefined,
|
|
||||||
memoryMb: memoryMb.value,
|
|
||||||
})
|
|
||||||
createdServer.value = manifest
|
|
||||||
|
|
||||||
// [SERVER-INSTALL] The shared orchestrator owns the sidebar download
|
|
||||||
// job, progress/log event forwarding, and cancellation. Each server
|
|
||||||
// type supplies a `ServerInstallStrategy` that knows how to obtain its
|
|
||||||
// launcher files; vanilla/Fabric/Paper download a jar, Forge runs its
|
|
||||||
// installer. This is the single reuse point for every server type.
|
|
||||||
const strategy = getServerInstallStrategy(serverType.value)
|
|
||||||
await runServerInstall({
|
|
||||||
serverId: manifest.id,
|
|
||||||
name: name.value,
|
|
||||||
inputs: {
|
|
||||||
gameVersion: selectedGameVersion.value,
|
|
||||||
loaderVersion: selectedLoaderVersion.value || undefined,
|
|
||||||
javaPath: selectedJava.value.path || undefined,
|
|
||||||
memoryMb: memoryMb.value,
|
|
||||||
},
|
|
||||||
strategy,
|
|
||||||
downloadManager,
|
|
||||||
onProgress: (progress) => {
|
|
||||||
downloadProgress.value = progress
|
|
||||||
},
|
|
||||||
onLog: (line) => {
|
|
||||||
installLog.value.push(line)
|
|
||||||
if (installLog.value.length > 500) {
|
|
||||||
installLog.value.splice(0, installLog.value.length - 500)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// [SERVER-EULA] Like the modpack flow, the server is not auto-started.
|
|
||||||
// A code-created `eula.txt` (eula=false) is written so the manual start
|
|
||||||
// gate (useServerLifecycle) can offer the EULA without booting the jar.
|
|
||||||
const eula = setEulaAccepted('', false)
|
|
||||||
await servers.writeFile(manifest.id, 'eula.txt', eula).catch(() => {})
|
|
||||||
installPhase.value = 'done'
|
|
||||||
} catch (error) {
|
|
||||||
installPhase.value = 'error'
|
|
||||||
installError.value = toErrorMessage(error)
|
|
||||||
// A half-installed server must not linger in the list; retrying starts over.
|
|
||||||
if (createdServer.value) {
|
|
||||||
const failed = createdServer.value
|
|
||||||
createdServer.value = null
|
|
||||||
await servers.delete(failed.id).catch(() => {})
|
|
||||||
void refreshServerList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryInstall(): Promise<void> {
|
|
||||||
installPhase.value = 'idle'
|
|
||||||
return beginInstall()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acceptEula() {
|
|
||||||
if (!createdServer.value) return
|
|
||||||
try {
|
|
||||||
const updated = setEulaAccepted(eulaText.value, true)
|
|
||||||
await servers.writeFile(createdServer.value.id, 'eula.txt', updated)
|
|
||||||
showEulaModal.value = false
|
|
||||||
installPhase.value = 'done'
|
|
||||||
} catch (error) {
|
|
||||||
installError.value = toErrorMessage(error)
|
|
||||||
installPhase.value = 'error'
|
|
||||||
showEulaModal.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function declineEula() {
|
|
||||||
showEulaModal.value = false
|
|
||||||
modal.value?.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
serverType.value = 'vanilla'
|
|
||||||
selectedGameVersion.value = ''
|
|
||||||
selectedLoaderVersion.value = ''
|
|
||||||
loaderVersions.value = []
|
|
||||||
name.value = ''
|
|
||||||
selectedJava.value = { path: '', version: '' }
|
|
||||||
memoryMb.value = 2048
|
|
||||||
installPhase.value = 'idle'
|
|
||||||
installLog.value = []
|
|
||||||
installError.value = null
|
|
||||||
downloadProgress.value = null
|
|
||||||
eulaText.value = ''
|
|
||||||
createdServer.value = null
|
|
||||||
showEulaModal.value = false
|
|
||||||
saveServerProperties.value = null
|
|
||||||
void loadVersions()
|
|
||||||
void loadMaxMemory()
|
|
||||||
}
|
|
||||||
|
|
||||||
const canContinueFromType = computed(
|
|
||||||
() =>
|
|
||||||
typeSupported.value &&
|
|
||||||
selectedGameVersion.value !== '' &&
|
|
||||||
(!needsLoaderVersion.value || selectedLoaderVersion.value !== ''),
|
|
||||||
)
|
|
||||||
|
|
||||||
const stageConfigs: StageConfigInput<CreateServerFlowContextValue>[] = [
|
|
||||||
{
|
|
||||||
id: 'type',
|
|
||||||
stageContent: markRaw(TypeStage),
|
|
||||||
title: (ctx) => ctx.formatMessage(wizardMessages.typeStageTitle),
|
|
||||||
cannotNavigateForward: (ctx) => !ctx.canContinueFromType.value,
|
|
||||||
leftButtonConfig: () => null,
|
|
||||||
rightButtonConfig: (ctx) => ({
|
|
||||||
label: ctx.formatMessage(wizardMessages.next),
|
|
||||||
color: 'brand',
|
|
||||||
disabled: !ctx.canContinueFromType.value,
|
|
||||||
onClick: () => ctx.modal.value?.nextStage(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'setup',
|
|
||||||
stageContent: markRaw(SetupStage),
|
|
||||||
title: (ctx) => ctx.formatMessage(wizardMessages.setupStageTitle),
|
|
||||||
cannotNavigateForward: (ctx) => ctx.name.value.trim() === '',
|
|
||||||
leftButtonConfig: () => null,
|
|
||||||
rightButtonConfig: (ctx) => ({
|
|
||||||
label: ctx.formatMessage(wizardMessages.next),
|
|
||||||
color: 'brand',
|
|
||||||
disabled: ctx.name.value.trim() === '',
|
|
||||||
onClick: async () => {
|
|
||||||
await ctx.loadDefaultJava()
|
|
||||||
ctx.modal.value?.nextStage()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'install',
|
|
||||||
stageContent: markRaw(InstallStage),
|
|
||||||
title: (ctx) => ctx.formatMessage(wizardMessages.installStageTitle),
|
|
||||||
cannotNavigateForward: (ctx) => ctx.installPhase.value !== 'done',
|
|
||||||
// Downloads continue in the background once the wizard closes; only
|
|
||||||
// the first-run boot locks closing until the server reaches its EULA gate.
|
|
||||||
disableClose: (ctx) => ctx.installPhase.value === 'first-run',
|
|
||||||
leftButtonConfig: () => null,
|
|
||||||
rightButtonConfig: (ctx) => ({
|
|
||||||
label: ctx.formatMessage(
|
|
||||||
ctx.installPhase.value === 'error' ? wizardMessages.retry : wizardMessages.finish,
|
|
||||||
),
|
|
||||||
color: 'brand',
|
|
||||||
icon: ctx.installPhase.value === 'error' ? RefreshCwIcon : null,
|
|
||||||
iconPosition: 'after',
|
|
||||||
disabled: ctx.installPhase.value !== 'done' && ctx.installPhase.value !== 'error',
|
|
||||||
onClick: () => {
|
|
||||||
if (ctx.installPhase.value === 'error') {
|
|
||||||
ctx.retryInstall()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Server is ready — close the wizard so the host can navigate to it.
|
|
||||||
ctx.modal.value?.hide()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
modal,
|
|
||||||
stageConfigs,
|
|
||||||
formatMessage,
|
|
||||||
serverType,
|
|
||||||
availableGameVersions,
|
|
||||||
selectedGameVersion,
|
|
||||||
showSnapshots,
|
|
||||||
loaderVersions,
|
|
||||||
selectedLoaderVersion,
|
|
||||||
isVersionsLoading,
|
|
||||||
versionsError,
|
|
||||||
name,
|
|
||||||
selectedJava,
|
|
||||||
memoryMb,
|
|
||||||
maxMemoryMb,
|
|
||||||
installPhase,
|
|
||||||
downloadProgress,
|
|
||||||
installLog,
|
|
||||||
installError,
|
|
||||||
eulaText,
|
|
||||||
createdServer,
|
|
||||||
showEulaModal,
|
|
||||||
saveServerProperties,
|
|
||||||
needsLoaderVersion,
|
|
||||||
typeSupported,
|
|
||||||
canContinueFromType,
|
|
||||||
loadVersions,
|
|
||||||
loadLoaderVersions,
|
|
||||||
loadDefaultJava,
|
|
||||||
beginInstall,
|
|
||||||
retryInstall,
|
|
||||||
acceptEula,
|
|
||||||
declineEula,
|
|
||||||
reset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
|
||||||
import { commonMessages, defineMessages, MultiStageModal } from '@modrinth/ui'
|
|
||||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
|
||||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
||||||
|
|
||||||
import { provideCreateServerFlow } from '@/components/multiplayer/servers/create-server-flow'
|
|
||||||
import EulaModal from '@/components/multiplayer/servers/EulaModal.vue'
|
|
||||||
import {
|
|
||||||
createModpackServerFlowContext,
|
|
||||||
provideModpackServerFlow,
|
|
||||||
} from '@/components/multiplayer/servers/modpack/create-modpack-server-flow'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
created: [serverId: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
|
|
||||||
const eulaModal = useTemplateRef<ComponentExposed<typeof EulaModal>>('eulaModal')
|
|
||||||
|
|
||||||
const ctx = createModpackServerFlowContext(modal)
|
|
||||||
provideCreateServerFlow(ctx)
|
|
||||||
provideModpackServerFlow(ctx)
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
downloadInBackground: {
|
|
||||||
id: 'app.servers.modpack.download-in-background',
|
|
||||||
defaultMessage: 'Download in background',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const wizardShown = ref(false)
|
|
||||||
const wasHiddenDuringInstall = ref(false)
|
|
||||||
const creationReported = ref(false)
|
|
||||||
|
|
||||||
const cancelButton = computed(() => {
|
|
||||||
if (ctx.installPhase.value === 'done') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label: ctx.formatMessage(
|
|
||||||
ctx.installPhase.value === 'downloading'
|
|
||||||
? messages.downloadInBackground
|
|
||||||
: commonMessages.cancelButton,
|
|
||||||
),
|
|
||||||
disabled: ctx.installPhase.value === 'first-run',
|
|
||||||
onClick: () => modal.value?.hide(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(ctx.showEulaModal, (visible) => {
|
|
||||||
if (visible) {
|
|
||||||
// When the setup finished in the background, don't pop a EULA dialog over
|
|
||||||
// whatever page the user is on; starting the server gates on it instead.
|
|
||||||
if (wizardShown.value) eulaModal.value?.show()
|
|
||||||
} else {
|
|
||||||
eulaModal.value?.hide()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// The download keeps running in the background even if the wizard is closed.
|
|
||||||
// Report the finished server once the flow reaches a terminal success state.
|
|
||||||
watch(
|
|
||||||
() => ctx.installPhase.value,
|
|
||||||
(phase) => {
|
|
||||||
if (!wasHiddenDuringInstall.value || creationReported.value) return
|
|
||||||
if ((phase === 'done' || phase === 'eula') && ctx.createdServer.value) {
|
|
||||||
creationReported.value = true
|
|
||||||
emit('created', ctx.createdServer.value.id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function show(project: Labrinth.Projects.v2.Project, version: Labrinth.Versions.v2.Version) {
|
|
||||||
wizardShown.value = true
|
|
||||||
wasHiddenDuringInstall.value = false
|
|
||||||
creationReported.value = false
|
|
||||||
ctx.reset()
|
|
||||||
ctx.setPack(project, version)
|
|
||||||
modal.value?.setStage(0)
|
|
||||||
modal.value?.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHide() {
|
|
||||||
wizardShown.value = false
|
|
||||||
if (
|
|
||||||
ctx.createdServer.value &&
|
|
||||||
(ctx.installPhase.value === 'done' || ctx.installPhase.value === 'eula')
|
|
||||||
) {
|
|
||||||
if (!creationReported.value) {
|
|
||||||
creationReported.value = true
|
|
||||||
emit('created', ctx.createdServer.value.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wasHiddenDuringInstall.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ show, hide: () => modal.value?.hide() })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<MultiStageModal
|
|
||||||
ref="modal"
|
|
||||||
:stages="ctx.stageConfigs"
|
|
||||||
:context="ctx"
|
|
||||||
breadcrumbs
|
|
||||||
:back-button-enabled="
|
|
||||||
(flowCtx) =>
|
|
||||||
flowCtx.installPhase.value !== 'downloading' && flowCtx.installPhase.value !== 'first-run'
|
|
||||||
"
|
|
||||||
:cancel-button="cancelButton"
|
|
||||||
@hide="handleHide"
|
|
||||||
/>
|
|
||||||
<EulaModal
|
|
||||||
ref="eulaModal"
|
|
||||||
:text="ctx.eulaText.value"
|
|
||||||
@continue="ctx.acceptEula"
|
|
||||||
@decline="ctx.declineEula"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
@ -1,537 +0,0 @@
|
|||||||
import type { Labrinth } from '@modrinth/api-client'
|
|
||||||
import { RefreshCwIcon } from '@modrinth/assets'
|
|
||||||
import { type ServerTypeId, setEulaAccepted } from '@modrinth/server'
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
defineMessages,
|
|
||||||
type MultiStageModal,
|
|
||||||
type StageConfigInput,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { computed, markRaw, type Ref, ref } from 'vue'
|
|
||||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
||||||
|
|
||||||
import { startModpackServerInstall } from '@/composables/useServerInstalls'
|
|
||||||
import { refresh as refreshServerList } from '@/composables/useServers'
|
|
||||||
import { find_filtered_jres, get_java_default_versions, get_max_memory } from '@/helpers/jre'
|
|
||||||
import { get_loader_versions } from '@/helpers/metadata'
|
|
||||||
import { serverEventListener, type ServerManifestData, servers } from '@/helpers/servers'
|
|
||||||
import { injectDownloadManager } from '@/providers/download-manager'
|
|
||||||
|
|
||||||
import type { CreateServerFlowContext, JavaSelection } from '../create-server-flow'
|
|
||||||
import {
|
|
||||||
javaMajorFromVersion,
|
|
||||||
resolveServerLauncher,
|
|
||||||
toErrorMessage,
|
|
||||||
waitForServerStop,
|
|
||||||
} from '../server-flow-utils'
|
|
||||||
import ModpackInstallStage from './stages/ModpackInstallStage.vue'
|
|
||||||
import ModpackSetupStage from './stages/ModpackSetupStage.vue'
|
|
||||||
|
|
||||||
export type ModpackInstallPhase =
|
|
||||||
| 'idle'
|
|
||||||
| 'preparing'
|
|
||||||
| 'downloading'
|
|
||||||
| 'first-run'
|
|
||||||
| 'eula'
|
|
||||||
| 'error'
|
|
||||||
| 'done'
|
|
||||||
|
|
||||||
export interface ModpackServerOptions {
|
|
||||||
project: Labrinth.Projects.v2.Project
|
|
||||||
version: Labrinth.Versions.v2.Version
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModpackServerFlowContext extends CreateServerFlowContext<ModpackServerFlowContext> {
|
|
||||||
modpackTitle: Ref<string>
|
|
||||||
modpackVersionNumber: Ref<string>
|
|
||||||
modpackIconUrl: Ref<string | undefined>
|
|
||||||
loaderLabel: Ref<string>
|
|
||||||
loaderSupported: Ref<boolean>
|
|
||||||
gameVersionLabel: Ref<string>
|
|
||||||
setPack: (project: Labrinth.Projects.v2.Project, version: Labrinth.Versions.v2.Version) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const [injectModpackServerFlow, provideModpackServerFlow] =
|
|
||||||
createContext<ModpackServerFlowContext>('ModpackServerFlow')
|
|
||||||
|
|
||||||
const MODPACK_SERVER_TYPES: Record<string, { type: ServerTypeId; label: string }> = {
|
|
||||||
fabric: { type: 'fabric', label: 'Fabric' },
|
|
||||||
quilt: { type: 'quilt', label: 'Quilt' },
|
|
||||||
neoforge: { type: 'neoforge', label: 'NeoForge' },
|
|
||||||
forge: { type: 'forge', label: 'Forge' },
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loaders whose server launcher the app can download and boot directly. */
|
|
||||||
const SUPPORTED_MODPACK_LOADERS: ServerTypeId[] = ['vanilla', 'fabric', 'quilt', 'forge']
|
|
||||||
|
|
||||||
export function resolveModpackLoader(loaders: string[]): { type: ServerTypeId; label: string } {
|
|
||||||
for (const loader of loaders) {
|
|
||||||
const entry = MODPACK_SERVER_TYPES[loader.toLowerCase()]
|
|
||||||
if (entry) return entry
|
|
||||||
}
|
|
||||||
return { type: 'vanilla', label: 'Vanilla' }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createModpackServerFlowContext(
|
|
||||||
modal: Ref<ComponentExposed<typeof MultiStageModal> | null>,
|
|
||||||
): ModpackServerFlowContext {
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
|
|
||||||
// [SERVER-DOWNLOAD-BRIDGE] Capture the download manager once during Vue
|
|
||||||
// setup context. Vue's inject() only works in the synchronous setup
|
|
||||||
// scope — after any `await` the injection context is lost. We store
|
|
||||||
// the reference here and pass it explicitly to `startModpackServerInstall`.
|
|
||||||
let downloadManager: ReturnType<typeof injectDownloadManager> | null = null
|
|
||||||
try {
|
|
||||||
downloadManager = injectDownloadManager()
|
|
||||||
} catch {
|
|
||||||
// Not inside a provider tree — server downloads will not appear in sidebar.
|
|
||||||
}
|
|
||||||
|
|
||||||
const wizardMessages = defineMessages({
|
|
||||||
setupTitle: { id: 'app.servers.wizard.setup-title', defaultMessage: 'Setup' },
|
|
||||||
installTitle: { id: 'app.servers.wizard.install-title', defaultMessage: 'Install' },
|
|
||||||
configureTitle: { id: 'app.servers.wizard.configure-title', defaultMessage: 'Configure' },
|
|
||||||
next: { id: 'app.servers.wizard.next', defaultMessage: 'Next' },
|
|
||||||
retry: { id: 'app.servers.wizard.retry', defaultMessage: 'Retry' },
|
|
||||||
finish: { id: 'app.servers.wizard.finish', defaultMessage: 'Finish' },
|
|
||||||
javaTooOld: {
|
|
||||||
id: 'app.servers.wizard.java-too-old',
|
|
||||||
defaultMessage:
|
|
||||||
'Java {selected} cannot run this game version; Java {required} or newer is required.',
|
|
||||||
},
|
|
||||||
firstRunCrashed: {
|
|
||||||
id: 'app.servers.modpack.first-run-crashed',
|
|
||||||
defaultMessage:
|
|
||||||
'The server crashed during its first start. Check that your selected Java version is compatible, then try again.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const project = ref<Labrinth.Projects.v2.Project | null>(null)
|
|
||||||
const version = ref<Labrinth.Versions.v2.Version | null>(null)
|
|
||||||
|
|
||||||
const serverType = ref<ServerTypeId>('vanilla')
|
|
||||||
const availableGameVersions = ref<string[]>([])
|
|
||||||
const selectedGameVersion = ref('')
|
|
||||||
const showSnapshots = ref(false)
|
|
||||||
const loaderVersions = ref<{ id: string; stable: boolean }[]>([])
|
|
||||||
const selectedLoaderVersion = ref('')
|
|
||||||
const isVersionsLoading = ref(false)
|
|
||||||
const versionsError = ref<string | null>(null)
|
|
||||||
|
|
||||||
const name = ref('')
|
|
||||||
const selectedJava = ref<JavaSelection>({ path: '', version: '' })
|
|
||||||
const memoryMb = ref(2048)
|
|
||||||
const maxMemoryMb = ref(8192)
|
|
||||||
|
|
||||||
const installPhase = ref<ModpackInstallPhase>('idle')
|
|
||||||
const downloadProgress = ref<{ downloaded: number; total: number | null } | null>(null)
|
|
||||||
const installLog = ref<string[]>([])
|
|
||||||
const installError = ref<string | null>(null)
|
|
||||||
const eulaText = ref('')
|
|
||||||
const createdServer = ref<ServerManifestData | null>(null)
|
|
||||||
const showEulaModal = ref(false)
|
|
||||||
const saveServerProperties = ref<(() => Promise<boolean>) | null>(null)
|
|
||||||
let installSession = 0
|
|
||||||
|
|
||||||
const modpackTitle = ref('')
|
|
||||||
const modpackVersionNumber = ref('')
|
|
||||||
const modpackIconUrl = ref<string | undefined>(undefined)
|
|
||||||
const loaderLabel = ref('')
|
|
||||||
const loaderSupported = ref(false)
|
|
||||||
const gameVersionLabel = ref('')
|
|
||||||
|
|
||||||
const needsLoaderVersion = computed(
|
|
||||||
() => serverType.value === 'fabric' || serverType.value === 'quilt',
|
|
||||||
)
|
|
||||||
const typeSupported = computed(() => loaderSupported.value)
|
|
||||||
const canContinueFromType = computed(() => loaderSupported.value)
|
|
||||||
|
|
||||||
function setPack(
|
|
||||||
packProject: Labrinth.Projects.v2.Project,
|
|
||||||
packVersion: Labrinth.Versions.v2.Version,
|
|
||||||
) {
|
|
||||||
project.value = packProject
|
|
||||||
version.value = packVersion
|
|
||||||
modpackTitle.value = packProject.title
|
|
||||||
modpackVersionNumber.value = packVersion.version_number ?? ''
|
|
||||||
modpackIconUrl.value = packProject.icon_url ?? undefined
|
|
||||||
|
|
||||||
const gameVersion = packVersion.game_versions?.[0] ?? packProject.game_versions?.[0] ?? ''
|
|
||||||
// Merge both the project-level and version-level loader declarations.
|
|
||||||
// Modpack versions frequently leave `version.loaders` empty (the project
|
|
||||||
// field is the reliable source); the authoritative source is the mrpack's
|
|
||||||
// `modrinth.index.json` dependencies, but that is only available after
|
|
||||||
// download. See resolveModpackLoader's fallback note.
|
|
||||||
const loaderCandidates = [...(packProject.loaders ?? []), ...(packVersion.loaders ?? [])]
|
|
||||||
const loader = resolveModpackLoader(loaderCandidates)
|
|
||||||
serverType.value = loader.type
|
|
||||||
loaderLabel.value = loader.label
|
|
||||||
gameVersionLabel.value = gameVersion
|
|
||||||
selectedGameVersion.value = gameVersion
|
|
||||||
availableGameVersions.value = gameVersion ? [gameVersion] : []
|
|
||||||
loaderSupported.value = SUPPORTED_MODPACK_LOADERS.includes(loader.type)
|
|
||||||
|
|
||||||
// Default the server name to `<modpack title> <version number>` so different
|
|
||||||
// versions of the same modpack produce distinct server names instead of
|
|
||||||
// colliding. A short uid is appended only if a name collision remains
|
|
||||||
// (see beginInstall), mirroring the direct-server id style.
|
|
||||||
name.value = `${packProject.title} ${packVersion.version_number ?? ''}`.trim()
|
|
||||||
selectedLoaderVersion.value = ''
|
|
||||||
loaderVersions.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadVersions() {
|
|
||||||
// The modpack fixes the game version; nothing to load.
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLoaderVersions() {
|
|
||||||
selectedLoaderVersion.value = ''
|
|
||||||
loaderVersions.value = []
|
|
||||||
if (!needsLoaderVersion.value || !selectedGameVersion.value) return
|
|
||||||
try {
|
|
||||||
const manifest = (await get_loader_versions(serverType.value, selectedGameVersion.value)) as {
|
|
||||||
gameVersions: Array<{ id: string; loaders: { id: string; stable: boolean }[] }>
|
|
||||||
}
|
|
||||||
const entry = manifest.gameVersions.find((game) => game.id === selectedGameVersion.value)
|
|
||||||
loaderVersions.value = entry?.loaders ?? []
|
|
||||||
const stable = loaderVersions.value.find((option) => option.stable) ?? loaderVersions.value[0]
|
|
||||||
selectedLoaderVersion.value = stable?.id ?? ''
|
|
||||||
} catch {
|
|
||||||
loaderVersions.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDefaultJava() {
|
|
||||||
if (selectedJava.value.path !== '') return
|
|
||||||
const major = javaMajorFromVersion(selectedGameVersion.value || '1.21') ?? 21
|
|
||||||
try {
|
|
||||||
const defaults = (await get_java_default_versions()) as Array<{
|
|
||||||
parsed_version: number
|
|
||||||
version: string
|
|
||||||
path: string
|
|
||||||
}>
|
|
||||||
const match =
|
|
||||||
defaults.find((entry) => entry.parsed_version === major) ??
|
|
||||||
defaults.find((entry) => entry.parsed_version >= major)
|
|
||||||
if (match) {
|
|
||||||
selectedJava.value = { path: match.path, version: match.version }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to a filtered scan
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const javas = (await find_filtered_jres(major)) as JavaSelection[]
|
|
||||||
if (javas.length > 0) selectedJava.value = javas[0]
|
|
||||||
} catch {
|
|
||||||
// Leave empty; the user picks manually in the setup stage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMaxMemory() {
|
|
||||||
try {
|
|
||||||
const maxKiB = (await get_max_memory()) as number
|
|
||||||
maxMemoryMb.value = Math.max(1024, Math.floor(maxKiB / 1024))
|
|
||||||
} catch {
|
|
||||||
maxMemoryMb.value = 8192
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function beginInstall() {
|
|
||||||
if (installPhase.value === 'downloading' || installPhase.value === 'first-run') return
|
|
||||||
if (!project.value || !version.value) return
|
|
||||||
if (!loaderSupported.value) return
|
|
||||||
|
|
||||||
// A closed wizard leaves its install promise running in the background.
|
|
||||||
// Reopening the wizard starts a fresh session; stale sessions must stop
|
|
||||||
// touching the shared state once their token is superseded.
|
|
||||||
const session = ++installSession
|
|
||||||
const isStale = () => installSession !== session
|
|
||||||
|
|
||||||
installPhase.value = 'preparing'
|
|
||||||
installError.value = null
|
|
||||||
installLog.value = []
|
|
||||||
downloadProgress.value = null
|
|
||||||
try {
|
|
||||||
await loadLoaderVersions()
|
|
||||||
if (isStale()) return
|
|
||||||
|
|
||||||
const requiredJava = javaMajorFromVersion(selectedGameVersion.value) ?? 21
|
|
||||||
const selectedMajor = javaMajorFromVersion(selectedJava.value.version)
|
|
||||||
if (
|
|
||||||
selectedJava.value.path !== '' &&
|
|
||||||
selectedMajor !== null &&
|
|
||||||
selectedMajor < requiredJava
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
formatMessage(wizardMessages.javaTooOld, {
|
|
||||||
selected: selectedMajor,
|
|
||||||
required: requiredJava,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!createdServer.value) {
|
|
||||||
// Ensure the chosen name is unique among existing servers. When it
|
|
||||||
// collides we append a short uid (mirroring the direct-server id
|
|
||||||
// style) so duplicate modpack versions stay distinguishable; if the
|
|
||||||
// name is free, no suffix is added.
|
|
||||||
let finalName = name.value.trim()
|
|
||||||
try {
|
|
||||||
const existing = await servers.list()
|
|
||||||
const taken = new Set(existing.map((server) => server.name.trim().toLowerCase()))
|
|
||||||
if (taken.has(finalName.toLowerCase())) {
|
|
||||||
const uid = Math.random().toString(36).slice(2, 6)
|
|
||||||
finalName = `${finalName} ${uid}`
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort uniqueness; the backend id already disambiguates.
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = await servers.create({
|
|
||||||
name: finalName,
|
|
||||||
serverType: serverType.value,
|
|
||||||
gameVersion: selectedGameVersion.value,
|
|
||||||
loaderVersion: needsLoaderVersion.value ? selectedLoaderVersion.value : undefined,
|
|
||||||
javaPath: selectedJava.value.path || undefined,
|
|
||||||
memoryMb: memoryMb.value,
|
|
||||||
})
|
|
||||||
if (isStale()) {
|
|
||||||
await servers.delete(manifest.id).catch(() => {})
|
|
||||||
void refreshServerList()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createdServer.value = manifest
|
|
||||||
}
|
|
||||||
const serverId = createdServer.value.id
|
|
||||||
|
|
||||||
// Past this point the server directory exists and the backend tracks
|
|
||||||
// install state on its manifest, so failures leave a retryable entry
|
|
||||||
// instead of being cleaned up. Only pre-install resolution errors
|
|
||||||
// (no launcher, no pack file) still remove the stub.
|
|
||||||
let dispatched = false
|
|
||||||
const unlistenEvents = await serverEventListener((id, payload) => {
|
|
||||||
if (id !== serverId || isStale()) return
|
|
||||||
if (payload.event === 'download_progress') {
|
|
||||||
downloadProgress.value = {
|
|
||||||
downloaded: payload.downloaded,
|
|
||||||
total: payload.total ?? null,
|
|
||||||
}
|
|
||||||
} else if (payload.event === 'log') {
|
|
||||||
installLog.value.push(payload.line)
|
|
||||||
if (installLog.value.length > 500) {
|
|
||||||
installLog.value.splice(0, installLog.value.length - 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
const jar = await resolveServerLauncher(
|
|
||||||
serverType.value,
|
|
||||||
selectedGameVersion.value,
|
|
||||||
selectedLoaderVersion.value,
|
|
||||||
)
|
|
||||||
if (!jar) {
|
|
||||||
throw new Error(
|
|
||||||
`No server launcher available for ${loaderLabel.value} on ${selectedGameVersion.value}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryFile =
|
|
||||||
version.value.files.find((file) => file.primary) ?? version.value.files[0]
|
|
||||||
if (!primaryFile?.url) {
|
|
||||||
throw new Error('Modpack has no downloadable file')
|
|
||||||
}
|
|
||||||
|
|
||||||
// The download runs through the shared background runner, so closing
|
|
||||||
// the wizard keeps it going; progress renders from the shared registry.
|
|
||||||
dispatched = true
|
|
||||||
installPhase.value = 'downloading'
|
|
||||||
// [SERVER-DOWNLOAD-BRIDGE] Pass the download manager reference
|
|
||||||
// captured during setup so the synthetic job appears in sidebar.
|
|
||||||
await startModpackServerInstall(
|
|
||||||
serverId,
|
|
||||||
{
|
|
||||||
mrpackUrl: primaryFile.url,
|
|
||||||
mrpackSha1: primaryFile.hashes?.sha1,
|
|
||||||
jarUrl: jar.url,
|
|
||||||
jarFilename: jar.filename,
|
|
||||||
jarSha1: jar.sha1,
|
|
||||||
modpackProjectId: project.value.id,
|
|
||||||
modpackVersionId: version.value.id,
|
|
||||||
modpackTitle: `${modpackTitle.value} ${modpackVersionNumber.value}`.trim(),
|
|
||||||
modpackIconUrl: modpackIconUrl.value,
|
|
||||||
},
|
|
||||||
downloadManager,
|
|
||||||
)
|
|
||||||
if (isStale()) return
|
|
||||||
|
|
||||||
// Modpack installation complete, no auto-start.
|
|
||||||
// User will click "Start" later, which will handle EULA check via tryStartServer.
|
|
||||||
// A code-created `eula.txt` (eula=false) is written so the manual start
|
|
||||||
// gate can offer the EULA without booting the jar.
|
|
||||||
const eula = setEulaAccepted('', false)
|
|
||||||
await servers.writeFile(serverId, 'eula.txt', eula).catch(() => {})
|
|
||||||
installPhase.value = 'done'
|
|
||||||
} catch (error) {
|
|
||||||
if (!dispatched && createdServer.value) {
|
|
||||||
const failed = createdServer.value
|
|
||||||
createdServer.value = null
|
|
||||||
await servers.delete(failed.id).catch(() => {})
|
|
||||||
void refreshServerList()
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
unlistenEvents()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
installPhase.value = 'error'
|
|
||||||
installError.value = toErrorMessage(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryInstall(): Promise<void> {
|
|
||||||
installPhase.value = 'idle'
|
|
||||||
return beginInstall()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acceptEula() {
|
|
||||||
if (!createdServer.value) return
|
|
||||||
try {
|
|
||||||
const updated = setEulaAccepted(eulaText.value, true)
|
|
||||||
await servers.writeFile(createdServer.value.id, 'eula.txt', updated)
|
|
||||||
showEulaModal.value = false
|
|
||||||
installPhase.value = 'done'
|
|
||||||
// Start the server after accepting EULA
|
|
||||||
await servers.start(createdServer.value.id)
|
|
||||||
// Wait for server to stop (crash or normal)
|
|
||||||
const stopped = await waitForServerStop(createdServer.value.id)
|
|
||||||
if (stopped?.event === 'stopped' && stopped.crashed) {
|
|
||||||
throw new Error(formatMessage(wizardMessages.firstRunCrashed))
|
|
||||||
}
|
|
||||||
installPhase.value = 'done'
|
|
||||||
} catch (error) {
|
|
||||||
installError.value = toErrorMessage(error)
|
|
||||||
installPhase.value = 'error'
|
|
||||||
showEulaModal.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function declineEula() {
|
|
||||||
showEulaModal.value = false
|
|
||||||
modal.value?.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
installSession++
|
|
||||||
installPhase.value = 'idle'
|
|
||||||
installLog.value = []
|
|
||||||
installError.value = null
|
|
||||||
eulaText.value = ''
|
|
||||||
createdServer.value = null
|
|
||||||
showEulaModal.value = false
|
|
||||||
saveServerProperties.value = null
|
|
||||||
selectedJava.value = { path: '', version: '' }
|
|
||||||
memoryMb.value = 2048
|
|
||||||
void loadMaxMemory()
|
|
||||||
}
|
|
||||||
|
|
||||||
const stageConfigs: StageConfigInput<ModpackServerFlowContext>[] = [
|
|
||||||
{
|
|
||||||
id: 'setup',
|
|
||||||
stageContent: markRaw(ModpackSetupStage),
|
|
||||||
title: (ctx: ModpackServerFlowContext) => ctx.formatMessage(wizardMessages.setupTitle),
|
|
||||||
cannotNavigateForward: (ctx: ModpackServerFlowContext) =>
|
|
||||||
ctx.name.value.trim() === '' || !ctx.canContinueFromType.value,
|
|
||||||
leftButtonConfig: () => null,
|
|
||||||
rightButtonConfig: (ctx: ModpackServerFlowContext) => ({
|
|
||||||
label: ctx.formatMessage(wizardMessages.next),
|
|
||||||
color: 'brand',
|
|
||||||
disabled: ctx.name.value.trim() === '' || !ctx.canContinueFromType.value,
|
|
||||||
onClick: async () => {
|
|
||||||
await ctx.loadDefaultJava()
|
|
||||||
ctx.modal.value?.nextStage()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'install',
|
|
||||||
stageContent: markRaw(ModpackInstallStage),
|
|
||||||
title: (ctx: ModpackServerFlowContext) => ctx.formatMessage(wizardMessages.installTitle),
|
|
||||||
cannotNavigateForward: (ctx: ModpackServerFlowContext) => ctx.installPhase.value !== 'done',
|
|
||||||
// Downloads continue in the background once the wizard closes; only
|
|
||||||
// the first-run boot locks closing.
|
|
||||||
disableClose: (ctx: ModpackServerFlowContext) => ctx.installPhase.value === 'first-run',
|
|
||||||
leftButtonConfig: () => null,
|
|
||||||
rightButtonConfig: (ctx: ModpackServerFlowContext) => ({
|
|
||||||
label: ctx.formatMessage(
|
|
||||||
ctx.installPhase.value === 'error'
|
|
||||||
? wizardMessages.retry
|
|
||||||
: ctx.installPhase.value === 'done'
|
|
||||||
? wizardMessages.finish
|
|
||||||
: wizardMessages.next,
|
|
||||||
),
|
|
||||||
color: 'brand',
|
|
||||||
icon: ctx.installPhase.value === 'error' ? RefreshCwIcon : null,
|
|
||||||
iconPosition: 'after',
|
|
||||||
disabled: ctx.installPhase.value !== 'done' && ctx.installPhase.value !== 'error',
|
|
||||||
onClick: () => {
|
|
||||||
if (ctx.installPhase.value === 'error') {
|
|
||||||
void ctx.retryInstall()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (ctx.installPhase.value === 'done') {
|
|
||||||
ctx.modal.value?.hide()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.modal.value?.nextStage()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
modal,
|
|
||||||
stageConfigs,
|
|
||||||
formatMessage,
|
|
||||||
serverType,
|
|
||||||
availableGameVersions,
|
|
||||||
selectedGameVersion,
|
|
||||||
showSnapshots,
|
|
||||||
loaderVersions,
|
|
||||||
selectedLoaderVersion,
|
|
||||||
isVersionsLoading,
|
|
||||||
versionsError,
|
|
||||||
name,
|
|
||||||
selectedJava,
|
|
||||||
memoryMb,
|
|
||||||
maxMemoryMb,
|
|
||||||
installPhase,
|
|
||||||
downloadProgress,
|
|
||||||
installLog,
|
|
||||||
installError,
|
|
||||||
eulaText,
|
|
||||||
createdServer,
|
|
||||||
showEulaModal,
|
|
||||||
saveServerProperties,
|
|
||||||
needsLoaderVersion,
|
|
||||||
typeSupported,
|
|
||||||
canContinueFromType,
|
|
||||||
modpackTitle,
|
|
||||||
modpackVersionNumber,
|
|
||||||
modpackIconUrl,
|
|
||||||
loaderLabel,
|
|
||||||
loaderSupported,
|
|
||||||
gameVersionLabel,
|
|
||||||
loadVersions,
|
|
||||||
loadLoaderVersions,
|
|
||||||
loadDefaultJava,
|
|
||||||
beginInstall,
|
|
||||||
retryInstall,
|
|
||||||
acceptEula,
|
|
||||||
declineEula,
|
|
||||||
reset,
|
|
||||||
setPack,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { CheckCircleIcon, SpinnerIcon } from '@modrinth/assets'
|
|
||||||
import { Admonition, defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
|
|
||||||
import { injectCreateServerFlow } from '../../create-server-flow'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const ctx = injectCreateServerFlow()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
downloading: {
|
|
||||||
id: 'app.servers.modpack.downloading',
|
|
||||||
defaultMessage: 'Downloading modpack files...',
|
|
||||||
},
|
|
||||||
preparing: {
|
|
||||||
id: 'app.servers.modpack.preparing',
|
|
||||||
defaultMessage: 'Preparing server...',
|
|
||||||
},
|
|
||||||
done: { id: 'app.servers.modpack.done', defaultMessage: 'Installation complete' },
|
|
||||||
failed: { id: 'app.servers.wizard.failed', defaultMessage: 'Setup failed' },
|
|
||||||
installLog: { id: 'app.servers.wizard.log', defaultMessage: 'Output' },
|
|
||||||
currentFile: {
|
|
||||||
id: 'app.servers.modpack.current-file',
|
|
||||||
defaultMessage: 'Now installing {file}',
|
|
||||||
},
|
|
||||||
backgroundHint: {
|
|
||||||
id: 'app.servers.modpack.background-hint',
|
|
||||||
defaultMessage: 'You can close this window — the download continues in the background.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (ctx.installPhase.value === 'idle' || ctx.installPhase.value === 'error') {
|
|
||||||
void ctx.beginInstall()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const phaseText = computed(() => {
|
|
||||||
switch (ctx.installPhase.value) {
|
|
||||||
case 'preparing':
|
|
||||||
return formatMessage(messages.preparing)
|
|
||||||
case 'done':
|
|
||||||
return formatMessage(messages.done)
|
|
||||||
case 'error':
|
|
||||||
return formatMessage(messages.failed)
|
|
||||||
default:
|
|
||||||
return formatMessage(messages.downloading)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const progressPercent = computed(() => {
|
|
||||||
const progress = ctx.downloadProgress.value
|
|
||||||
if (!progress || !progress.total) return 0
|
|
||||||
return Math.min(100, (progress.downloaded / progress.total) * 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentFile = computed(() => {
|
|
||||||
const match = [...ctx.installLog.value]
|
|
||||||
.map((line) => /^Downloading (.+)$/.exec(line)?.[1])
|
|
||||||
.filter(Boolean)
|
|
||||||
.at(-1)
|
|
||||||
return match ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const isBusy = computed(
|
|
||||||
() => ctx.installPhase.value === 'preparing' || ctx.installPhase.value === 'downloading',
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<SpinnerIcon v-if="isBusy" class="size-6 shrink-0 animate-spin text-orange" />
|
|
||||||
<CheckCircleIcon
|
|
||||||
v-else-if="ctx.installPhase.value === 'done'"
|
|
||||||
class="size-6 shrink-0 text-green"
|
|
||||||
/>
|
|
||||||
<span class="text-lg font-semibold text-contrast">{{ phaseText }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
v-if="ctx.installPhase.value === 'downloading'"
|
|
||||||
full-width
|
|
||||||
:progress="progressPercent"
|
|
||||||
:max="100"
|
|
||||||
:waiting="progressPercent === 0"
|
|
||||||
:label="formatMessage(messages.downloading)"
|
|
||||||
show-progress
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="currentFile && ctx.installPhase.value === 'downloading'"
|
|
||||||
class="m-0 -mt-2 truncate text-xs font-medium text-secondary"
|
|
||||||
>
|
|
||||||
{{ formatMessage(messages.currentFile, { file: currentFile }) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="ctx.installPhase.value === 'downloading'"
|
|
||||||
class="m-0 text-xs font-medium text-secondary"
|
|
||||||
>
|
|
||||||
{{ formatMessage(messages.backgroundHint) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Admonition
|
|
||||||
v-if="ctx.installPhase.value === 'error'"
|
|
||||||
type="critical"
|
|
||||||
:header="formatMessage(messages.failed)"
|
|
||||||
>
|
|
||||||
{{ ctx.installError.value }}
|
|
||||||
</Admonition>
|
|
||||||
|
|
||||||
<div v-if="ctx.installPhase.value === 'error'" class="flex flex-col gap-2">
|
|
||||||
<span class="text-sm font-semibold text-secondary">
|
|
||||||
{{ formatMessage(messages.installLog) }}
|
|
||||||
</span>
|
|
||||||
<pre
|
|
||||||
class="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-xl border border-solid border-surface-4 bg-surface-3 p-3 font-mono text-xs leading-relaxed text-primary"
|
|
||||||
>{{ ctx.installLog.value.slice(-40).join('\n') }}</pre
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ServerIcon } from '@modrinth/assets'
|
|
||||||
import { requiredJavaMajorVersion } from '@modrinth/server'
|
|
||||||
import {
|
|
||||||
Admonition,
|
|
||||||
Avatar,
|
|
||||||
defineMessages,
|
|
||||||
Slider,
|
|
||||||
StyledInput,
|
|
||||||
TagItem,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
|
|
||||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
|
||||||
|
|
||||||
import { injectModpackServerFlow } from '../create-modpack-server-flow'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const ctx = injectModpackServerFlow()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
name: { id: 'app.servers.wizard.name', defaultMessage: 'Server name' },
|
|
||||||
java: { id: 'app.servers.settings.java', defaultMessage: 'Java' },
|
|
||||||
memory: { id: 'app.servers.settings.memory', defaultMessage: 'Memory' },
|
|
||||||
memoryValue: { id: 'app.servers.wizard.memory-value', defaultMessage: '{value} MB' },
|
|
||||||
unsupportedLoaderTitle: {
|
|
||||||
id: 'app.servers.modpack.unsupported-loader-title',
|
|
||||||
defaultMessage: '{loader} servers are not supported yet',
|
|
||||||
},
|
|
||||||
unsupportedLoaderDescription: {
|
|
||||||
id: 'app.servers.modpack.unsupported-loader-description',
|
|
||||||
defaultMessage:
|
|
||||||
'This modpack uses {loader}, but Axolotl can only start modpack servers with vanilla, Fabric, or Quilt. Support for {loader} is coming soon.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const requiredJava = computed(() =>
|
|
||||||
requiredJavaMajorVersion(ctx.selectedGameVersion.value || '1.21'),
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void ctx.loadDefaultJava()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-3"
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
:src="ctx.modpackIconUrl.value"
|
|
||||||
:alt="ctx.modpackTitle.value"
|
|
||||||
size="56px"
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="m-0 truncate text-base font-bold leading-tight text-contrast">
|
|
||||||
{{ ctx.modpackTitle.value }}
|
|
||||||
</p>
|
|
||||||
<p class="m-0 mt-0.5 truncate text-sm font-medium text-secondary">
|
|
||||||
{{ ctx.modpackVersionNumber.value }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
||||||
<TagItem>
|
|
||||||
<span class="font-semibold">{{ ctx.loaderLabel.value }}</span>
|
|
||||||
</TagItem>
|
|
||||||
<TagItem v-if="ctx.gameVersionLabel.value">
|
|
||||||
<span class="font-semibold">{{ ctx.gameVersionLabel.value }}</span>
|
|
||||||
</TagItem>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Admonition
|
|
||||||
v-if="!ctx.loaderSupported.value"
|
|
||||||
type="critical"
|
|
||||||
:header="
|
|
||||||
formatMessage(messages.unsupportedLoaderTitle, {
|
|
||||||
loader: ctx.loaderLabel.value,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
formatMessage(messages.unsupportedLoaderDescription, {
|
|
||||||
loader: ctx.loaderLabel.value,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</Admonition>
|
|
||||||
|
|
||||||
<label class="flex min-w-0 flex-col gap-2" for="modpack-server-name">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
|
|
||||||
<StyledInput
|
|
||||||
id="modpack-server-name"
|
|
||||||
v-model="ctx.name.value"
|
|
||||||
:icon="ServerIcon"
|
|
||||||
:placeholder="ctx.modpackTitle.value"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-2">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.java) }}</span>
|
|
||||||
<JavaSelector
|
|
||||||
id="modpack-java-selector"
|
|
||||||
v-model="ctx.selectedJava.value"
|
|
||||||
:version="requiredJava"
|
|
||||||
select-all-versions
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-2">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.memory) }}</span>
|
|
||||||
<span
|
|
||||||
class="rounded-md border border-solid border-surface-5 bg-surface-3 px-2 py-1 text-xs font-semibold leading-none text-contrast"
|
|
||||||
>
|
|
||||||
{{ formatMessage(messages.memoryValue, { value: ctx.memoryMb.value }) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Slider v-model="ctx.memoryMb.value" :min="1024" :max="ctx.maxMemoryMb.value" :step="512" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
import {
|
|
||||||
type FabricInstallerVersionsResponse,
|
|
||||||
fabricInstallerVersionsUrl,
|
|
||||||
FORGE_MAVEN_URL,
|
|
||||||
forgePromotionsSlimUrl,
|
|
||||||
latestStablePaperBuild,
|
|
||||||
type PaperBuildsResponse,
|
|
||||||
paperBuildsUrl,
|
|
||||||
quiltInstallerVersionsUrl,
|
|
||||||
resolveServerJar,
|
|
||||||
type ServerJarDownload,
|
|
||||||
type ServerTypeId,
|
|
||||||
type VanillaVersionInfo,
|
|
||||||
} from '@modrinth/server'
|
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
|
||||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
|
||||||
import { type as osType } from '@tauri-apps/plugin-os'
|
|
||||||
|
|
||||||
import { get_game_versions } from '@/helpers/metadata'
|
|
||||||
import { serverEventListener, type ServerEventPayload } from '@/helpers/servers'
|
|
||||||
|
|
||||||
/** Best-effort conversion of an unknown error into a user-presentable string. */
|
|
||||||
export function toErrorMessage(error: unknown): string {
|
|
||||||
if (error instanceof Error) return error.message
|
|
||||||
if (typeof error === 'string') return error
|
|
||||||
if (error && typeof error === 'object') {
|
|
||||||
const record = error as Record<string, unknown>
|
|
||||||
for (const key of ['message', 'error', 'description'] as const) {
|
|
||||||
if (typeof record[key] === 'string') return record[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(error)
|
|
||||||
} catch {
|
|
||||||
return String(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extracts the Java major version from strings like `17`, `1.8`, or `21.0.1`. */
|
|
||||||
export function javaMajorFromVersion(version: string): number | null {
|
|
||||||
const parts = version
|
|
||||||
.split(/[._]/)
|
|
||||||
.map(Number)
|
|
||||||
.filter((value) => Number.isInteger(value) && value >= 0)
|
|
||||||
if (parts.length === 0) return null
|
|
||||||
if (parts[0] === 1 && parts.length > 1) return parts[1]
|
|
||||||
return parts[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Waits for the server to emit a `stopped` event, resolving with the payload
|
|
||||||
* or `null` after a timeout. Used to run the first start during setup and know
|
|
||||||
* when the JVM has exited.
|
|
||||||
*/
|
|
||||||
export async function waitForServerStop(serverId: string): Promise<ServerEventPayload | null> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
void serverEventListener((eventServerId, payload) => {
|
|
||||||
if (eventServerId !== serverId || payload.event !== 'stopped') return
|
|
||||||
resolve(payload)
|
|
||||||
}).then((unlisten) => {
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
unlisten()
|
|
||||||
resolve(null)
|
|
||||||
},
|
|
||||||
10 * 60 * 1000,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let userAgentPromise: Promise<string> | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Identifying User-Agent, required by services like the PaperMC downloads API.
|
|
||||||
* Mirrors the format used by the Rust backend.
|
|
||||||
*/
|
|
||||||
function launcherUserAgent(): Promise<string> {
|
|
||||||
userAgentPromise ??= Promise.all([getVersion(), osType()]).then(
|
|
||||||
([version, platform]) =>
|
|
||||||
`garbage-human-studio/axolotl/${version} (${platform}; +https://www.ghs.red)`,
|
|
||||||
)
|
|
||||||
userAgentPromise = userAgentPromise.catch(
|
|
||||||
() => 'garbage-human-studio/axolotl (+https://www.ghs.red)',
|
|
||||||
)
|
|
||||||
return userAgentPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchJson<T>(url: string): Promise<T> {
|
|
||||||
const response = await tauriFetch(url, {
|
|
||||||
headers: { 'User-Agent': await launcherUserAgent() },
|
|
||||||
})
|
|
||||||
if (!response.ok) throw new Error('GET ' + url + ' failed: ' + response.status)
|
|
||||||
return (await response.json()) as T
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the server launcher jar download for a modpack server. Vanilla
|
|
||||||
* pulls the Mojang server jar; Fabric and Quilt use their meta service launcher
|
|
||||||
* jars with the newest stable installer.
|
|
||||||
*/
|
|
||||||
export async function resolveServerLauncher(
|
|
||||||
type: ServerTypeId,
|
|
||||||
gameVersion: string,
|
|
||||||
loaderVersion?: string,
|
|
||||||
): Promise<ServerJarDownload | null> {
|
|
||||||
switch (type) {
|
|
||||||
case 'vanilla': {
|
|
||||||
const manifest = (await get_game_versions()) as {
|
|
||||||
versions: { id: string; url: string }[]
|
|
||||||
}
|
|
||||||
const entry = manifest.versions.find((v) => v.id === gameVersion)
|
|
||||||
if (!entry) return null
|
|
||||||
const versionInfo = await fetchJson<VanillaVersionInfo>(entry.url)
|
|
||||||
return resolveServerJar('vanilla', { gameVersion, vanillaVersionInfo: versionInfo })
|
|
||||||
}
|
|
||||||
case 'fabric':
|
|
||||||
case 'quilt': {
|
|
||||||
const installers = await fetchJson<FabricInstallerVersionsResponse[]>(
|
|
||||||
type === 'fabric' ? fabricInstallerVersionsUrl() : quiltInstallerVersionsUrl(),
|
|
||||||
)
|
|
||||||
const installerVersion = installers[0]?.version
|
|
||||||
return resolveServerJar(type, { gameVersion, loaderVersion, installerVersion })
|
|
||||||
}
|
|
||||||
case 'paper': {
|
|
||||||
const builds = await fetchJson<PaperBuildsResponse>(paperBuildsUrl(gameVersion))
|
|
||||||
const build = latestStablePaperBuild(builds)
|
|
||||||
if (!build) return null
|
|
||||||
return resolveServerJar(type, { gameVersion, paperBuild: build })
|
|
||||||
}
|
|
||||||
case 'forge': {
|
|
||||||
// The Forge "launcher" is the installer jar; the backend runs it
|
|
||||||
// headlessly (`--installServer`) to materialize the server files.
|
|
||||||
const promos = await fetchJson<{ promos: Record<string, string> }>(forgePromotionsSlimUrl())
|
|
||||||
const build =
|
|
||||||
promos.promos[`${gameVersion}-recommended`] ?? promos.promos[`${gameVersion}-latest`]
|
|
||||||
if (!build) return null
|
|
||||||
const filename = `forge-${gameVersion}-${build}-installer.jar`
|
|
||||||
return {
|
|
||||||
url: `${FORGE_MAVEN_URL}/${gameVersion}-${build}/${filename}`,
|
|
||||||
filename,
|
|
||||||
sha1: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import type { ServerStatus } from '@modrinth/server'
|
|
||||||
import { defineMessages } from '@modrinth/ui'
|
|
||||||
|
|
||||||
export const serverStatusMessages = defineMessages({
|
|
||||||
created: { id: 'app.servers.status.created', defaultMessage: 'Not set up' },
|
|
||||||
eulaPending: { id: 'app.servers.status.eula-pending', defaultMessage: 'EULA pending' },
|
|
||||||
ready: { id: 'app.servers.status.ready', defaultMessage: 'Ready' },
|
|
||||||
starting: { id: 'app.servers.status.starting', defaultMessage: 'Starting' },
|
|
||||||
running: { id: 'app.servers.status.running', defaultMessage: 'Running' },
|
|
||||||
crashed: { id: 'app.servers.status.crashed', defaultMessage: 'Crashed' },
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface ServerStatusMeta {
|
|
||||||
label: (typeof serverStatusMessages)[keyof typeof serverStatusMessages]
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SERVER_STATUS_META: Record<ServerStatus, ServerStatusMeta> = {
|
|
||||||
created: { label: serverStatusMessages.created, color: 'text-secondary' },
|
|
||||||
eula_pending: { label: serverStatusMessages.eulaPending, color: 'text-orange' },
|
|
||||||
ready: { label: serverStatusMessages.ready, color: 'text-brand' },
|
|
||||||
starting: { label: serverStatusMessages.starting, color: 'text-orange' },
|
|
||||||
running: { label: serverStatusMessages.running, color: 'text-green' },
|
|
||||||
crashed: { label: serverStatusMessages.crashed, color: 'text-red' },
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Idle/closed states that should not render a status tag. */
|
|
||||||
export function isServerStatusVisible(status: ServerStatus): boolean {
|
|
||||||
return status !== 'created' && status !== 'ready'
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import type { ServerTypeId } from '@modrinth/server'
|
|
||||||
|
|
||||||
/** Color, monogram and icon used to badge a server type across cards and the wizard. */
|
|
||||||
export interface ServerTypeMeta {
|
|
||||||
colorVar: string
|
|
||||||
monogram: string
|
|
||||||
icon?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeIcon = (name: string) =>
|
|
||||||
new URL(`../../../assets/instance-icons/${name}`, import.meta.url).href
|
|
||||||
|
|
||||||
const PLATFORM_ID = (id: ServerTypeId) => `var(--color-platform-${id})`
|
|
||||||
|
|
||||||
export const SERVER_TYPE_META: Record<ServerTypeId, ServerTypeMeta> = {
|
|
||||||
vanilla: { colorVar: 'var(--color-brand)', monogram: 'V', icon: typeIcon('Mojang.svg') },
|
|
||||||
fabric: { colorVar: PLATFORM_ID('fabric'), monogram: 'F', icon: typeIcon('Fabric.png') },
|
|
||||||
paper: { colorVar: PLATFORM_ID('paper'), monogram: 'P', icon: typeIcon('Paper.svg') },
|
|
||||||
forge: { colorVar: PLATFORM_ID('forge'), monogram: 'Fo', icon: typeIcon('Forge.jpeg') },
|
|
||||||
neoforge: { colorVar: PLATFORM_ID('neoforge'), monogram: 'N' },
|
|
||||||
quilt: { colorVar: PLATFORM_ID('quilt'), monogram: 'Q' },
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
|
||||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
||||||
|
|
||||||
import ServerPropertiesEditor from '@/components/multiplayer/servers/ServerPropertiesEditor.vue'
|
|
||||||
|
|
||||||
import { injectCreateServerFlow } from '../create-server-flow'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const ctx = injectCreateServerFlow()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: {
|
|
||||||
id: 'app.servers.wizard.configure-heading',
|
|
||||||
defaultMessage: 'Adjust the server settings, or finish to edit them later.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const editor = useTemplateRef<ComponentExposed<typeof ServerPropertiesEditor>>('editor')
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
ctx.saveServerProperties.value = () => editor.value?.save() ?? Promise.resolve(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverId = computed(() => ctx.createdServer.value?.id ?? '')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<p class="m-0 text-sm text-secondary">
|
|
||||||
{{ formatMessage(messages.heading) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="max-h-[32rem] overflow-y-auto pr-2">
|
|
||||||
<ServerPropertiesEditor v-if="serverId !== ''" ref="editor" :server-id="serverId" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { CheckCircleIcon, SpinnerIcon } from '@modrinth/assets'
|
|
||||||
import { Admonition, defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
|
|
||||||
import { injectCreateServerFlow } from '../create-server-flow'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const ctx = injectCreateServerFlow()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
downloading: {
|
|
||||||
id: 'app.servers.wizard.downloading',
|
|
||||||
defaultMessage: 'Downloading server files...',
|
|
||||||
},
|
|
||||||
firstRun: { id: 'app.servers.wizard.first-run', defaultMessage: 'Running first start...' },
|
|
||||||
eulaWait: {
|
|
||||||
id: 'app.servers.wizard.eula-wait',
|
|
||||||
defaultMessage: 'Waiting for EULA confirmation',
|
|
||||||
},
|
|
||||||
done: { id: 'app.servers.wizard.done', defaultMessage: 'Server ready' },
|
|
||||||
failed: { id: 'app.servers.wizard.failed', defaultMessage: 'Setup failed' },
|
|
||||||
installLog: { id: 'app.servers.wizard.log', defaultMessage: 'Output' },
|
|
||||||
backgroundHint: {
|
|
||||||
id: 'app.servers.wizard.background-hint',
|
|
||||||
defaultMessage: 'You can close this window — the download continues in the background.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (ctx.installPhase.value === 'idle' || ctx.installPhase.value === 'error') {
|
|
||||||
void ctx.beginInstall()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const phaseText = computed(() => {
|
|
||||||
switch (ctx.installPhase.value) {
|
|
||||||
case 'first-run':
|
|
||||||
return formatMessage(messages.firstRun)
|
|
||||||
case 'eula':
|
|
||||||
return formatMessage(messages.eulaWait)
|
|
||||||
case 'done':
|
|
||||||
return formatMessage(messages.done)
|
|
||||||
case 'error':
|
|
||||||
return formatMessage(messages.failed)
|
|
||||||
default:
|
|
||||||
return formatMessage(messages.downloading)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const progressPercent = computed(() => {
|
|
||||||
const progress = ctx.downloadProgress.value
|
|
||||||
if (!progress || !progress.total) return 0
|
|
||||||
return Math.min(100, (progress.downloaded / progress.total) * 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isBusy = computed(
|
|
||||||
() =>
|
|
||||||
ctx.installPhase.value === 'preparing' ||
|
|
||||||
ctx.installPhase.value === 'downloading' ||
|
|
||||||
ctx.installPhase.value === 'first-run',
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<SpinnerIcon v-if="isBusy" class="size-6 shrink-0 animate-spin text-orange" />
|
|
||||||
<CheckCircleIcon
|
|
||||||
v-else-if="ctx.installPhase.value === 'done'"
|
|
||||||
class="size-6 shrink-0 text-green"
|
|
||||||
/>
|
|
||||||
<span class="text-lg font-semibold text-contrast">{{ phaseText }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
v-if="ctx.installPhase.value === 'downloading'"
|
|
||||||
full-width
|
|
||||||
:progress="progressPercent"
|
|
||||||
:max="100"
|
|
||||||
:waiting="progressPercent === 0"
|
|
||||||
:label="formatMessage(messages.downloading)"
|
|
||||||
show-progress
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="ctx.installPhase.value === 'downloading'"
|
|
||||||
class="m-0 text-xs font-medium text-secondary"
|
|
||||||
>
|
|
||||||
{{ formatMessage(messages.backgroundHint) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Admonition
|
|
||||||
v-if="ctx.installPhase.value === 'error'"
|
|
||||||
type="critical"
|
|
||||||
:header="formatMessage(messages.failed)"
|
|
||||||
>
|
|
||||||
{{ ctx.installError.value }}
|
|
||||||
</Admonition>
|
|
||||||
|
|
||||||
<div v-if="ctx.installPhase.value === 'error'" class="flex flex-col gap-2">
|
|
||||||
<span class="text-sm font-semibold text-secondary">
|
|
||||||
{{ formatMessage(messages.installLog) }}
|
|
||||||
</span>
|
|
||||||
<pre
|
|
||||||
class="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-xl border border-solid border-surface-4 bg-surface-3 p-3 font-mono text-xs leading-relaxed text-primary"
|
|
||||||
>{{ ctx.installLog.value.slice(-40).join('\n') }}</pre
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ServerIcon } from '@modrinth/assets'
|
|
||||||
import { requiredJavaMajorVersion } from '@modrinth/server'
|
|
||||||
import { defineMessages, Slider, StyledInput, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
|
|
||||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
|
||||||
|
|
||||||
import { injectCreateServerFlow } from '../create-server-flow'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const ctx = injectCreateServerFlow()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
name: { id: 'app.servers.wizard.name', defaultMessage: 'Server name' },
|
|
||||||
namePlaceholder: {
|
|
||||||
id: 'app.servers.wizard.name-placeholder',
|
|
||||||
defaultMessage: 'Survival server',
|
|
||||||
},
|
|
||||||
java: { id: 'app.servers.settings.java', defaultMessage: 'Java' },
|
|
||||||
memory: { id: 'app.servers.settings.memory', defaultMessage: 'Memory' },
|
|
||||||
memoryValue: { id: 'app.servers.wizard.memory-value', defaultMessage: '{value} MB' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const requiredJava = computed(() =>
|
|
||||||
requiredJavaMajorVersion(ctx.selectedGameVersion.value || '1.21'),
|
|
||||||
)
|
|
||||||
|
|
||||||
function suggestName() {
|
|
||||||
const type = ctx.serverType.value
|
|
||||||
const version = ctx.selectedGameVersion.value
|
|
||||||
const flag = Math.random().toString(16).slice(2, 6)
|
|
||||||
const segments = [type, version]
|
|
||||||
if (ctx.selectedLoaderVersion.value) segments.push(ctx.selectedLoaderVersion.value)
|
|
||||||
segments.push(flag)
|
|
||||||
ctx.name.value = segments.filter(Boolean).join('-')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void ctx.loadDefaultJava()
|
|
||||||
if (!ctx.name.value.trim() && ctx.selectedGameVersion.value) {
|
|
||||||
suggestName()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
<label class="flex min-w-0 flex-col gap-2" for="wizard-server-name">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
|
|
||||||
<StyledInput
|
|
||||||
id="wizard-server-name"
|
|
||||||
v-model="ctx.name.value"
|
|
||||||
:icon="ServerIcon"
|
|
||||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-2">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.java) }}</span>
|
|
||||||
<JavaSelector
|
|
||||||
id="wizard-java-selector"
|
|
||||||
v-model="ctx.selectedJava.value"
|
|
||||||
:version="requiredJava"
|
|
||||||
select-all-versions
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-2">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.memory) }}</span>
|
|
||||||
<span
|
|
||||||
class="rounded-md border border-solid border-surface-5 bg-surface-3 px-2 py-1 text-xs font-semibold leading-none text-contrast"
|
|
||||||
>
|
|
||||||
{{ formatMessage(messages.memoryValue, { value: ctx.memoryMb.value }) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Slider v-model="ctx.memoryMb.value" :min="1024" :max="ctx.maxMemoryMb.value" :step="512" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
isServerTypeSupported,
|
|
||||||
listServerTypes,
|
|
||||||
type ServerTypeDefinition,
|
|
||||||
type ServerTypeId,
|
|
||||||
} from '@modrinth/server'
|
|
||||||
import { Combobox, type ComboboxOption, defineMessages, Toggle, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import { injectCreateServerFlow } from '../create-server-flow'
|
|
||||||
import { SERVER_TYPE_META } from '../server-type'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const ctx = injectCreateServerFlow()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: { id: 'app.servers.wizard.type-heading', defaultMessage: 'Choose a server core' },
|
|
||||||
gameVersion: { id: 'app.servers.wizard.game-version', defaultMessage: 'Game version' },
|
|
||||||
loaderVersion: { id: 'app.servers.wizard.loader-version', defaultMessage: 'Loader version' },
|
|
||||||
showSnapshots: { id: 'app.servers.wizard.show-snapshots', defaultMessage: 'Show snapshots' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeLabels = defineMessages({
|
|
||||||
vanilla: { id: 'app.servers.type.vanilla', defaultMessage: 'Vanilla' },
|
|
||||||
fabric: { id: 'app.servers.type.fabric', defaultMessage: 'Fabric' },
|
|
||||||
paper: { id: 'app.servers.type.paper', defaultMessage: 'Paper' },
|
|
||||||
forge: { id: 'app.servers.type.forge', defaultMessage: 'Forge' },
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Display order for the wizard's type picker; Forge sits right after Fabric. */
|
|
||||||
const SERVER_TYPE_ORDER: ServerTypeId[] = ['vanilla', 'fabric', 'forge', 'paper']
|
|
||||||
|
|
||||||
function serverTypeLabel(type: ServerTypeDefinition): string {
|
|
||||||
const message = typeLabels[type.id as keyof typeof typeLabels]
|
|
||||||
return message ? formatMessage(message) : type.label
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverTypeOptions = listServerTypes()
|
|
||||||
.filter((type) => isServerTypeSupported(type.id))
|
|
||||||
.sort((a, b) => SERVER_TYPE_ORDER.indexOf(a.id) - SERVER_TYPE_ORDER.indexOf(b.id))
|
|
||||||
|
|
||||||
const gameVersionOptions = computed<ComboboxOption<string>[]>(() =>
|
|
||||||
ctx.availableGameVersions.value.map((version) => ({ value: version, label: version })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const loaderVersionOptions = computed<ComboboxOption<string>[]>(() =>
|
|
||||||
ctx.loaderVersions.value.map((loader) => ({ value: loader.id, label: loader.id })),
|
|
||||||
)
|
|
||||||
|
|
||||||
function selectType(typeId: string) {
|
|
||||||
ctx.serverType.value = typeId as ServerTypeId
|
|
||||||
void ctx.loadLoaderVersions()
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectGameVersion(version: string) {
|
|
||||||
ctx.selectedGameVersion.value = version
|
|
||||||
void ctx.loadLoaderVersions()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline styles instead of Tailwind arbitrary values: underscores inside
|
|
||||||
// `var(--_color)` are converted to spaces by Tailwind's arbitrary-value
|
|
||||||
// parsing, which generates invalid CSS and breaks the production build.
|
|
||||||
const monogramStyles = computed<Record<string, string>>(() =>
|
|
||||||
Object.fromEntries(
|
|
||||||
serverTypeOptions.map((type) => [
|
|
||||||
type.id,
|
|
||||||
`color-mix(in srgb, ${SERVER_TYPE_META[type.id].colorVar} 14%, transparent)`,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.heading) }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
|
||||||
<button
|
|
||||||
v-for="type in serverTypeOptions"
|
|
||||||
:key="type.id"
|
|
||||||
type="button"
|
|
||||||
class="flex items-center gap-2.5 rounded-lg border border-solid px-3 py-2.5 text-left transition-colors"
|
|
||||||
:class="
|
|
||||||
ctx.serverType.value === type.id
|
|
||||||
? 'border-brand bg-brand-highlight'
|
|
||||||
: 'border-surface-4 bg-surface-2 hover:border-surface-5'
|
|
||||||
"
|
|
||||||
@click="selectType(type.id)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="SERVER_TYPE_META[type.id].icon"
|
|
||||||
class="flex size-7 shrink-0 items-center justify-center overflow-hidden"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="SERVER_TYPE_META[type.id].icon"
|
|
||||||
:alt="serverTypeLabel(type)"
|
|
||||||
class="size-full object-contain"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="flex size-7 shrink-0 items-center justify-center rounded-md text-xs font-bold"
|
|
||||||
:style="{
|
|
||||||
color: SERVER_TYPE_META[type.id].colorVar,
|
|
||||||
backgroundColor: monogramStyles[type.id],
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ SERVER_TYPE_META[type.id].monogram }}
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0 truncate font-semibold text-contrast">{{
|
|
||||||
serverTypeLabel(type)
|
|
||||||
}}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end justify-between gap-4">
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
|
||||||
<span class="font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.gameVersion) }}
|
|
||||||
</span>
|
|
||||||
<Combobox
|
|
||||||
:model-value="ctx.selectedGameVersion.value"
|
|
||||||
:options="gameVersionOptions"
|
|
||||||
:placeholder="formatMessage(messages.gameVersion)"
|
|
||||||
@update:model-value="selectGameVersion"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center gap-2 pb-2.5">
|
|
||||||
<span class="whitespace-nowrap text-sm text-secondary">
|
|
||||||
{{ formatMessage(messages.showSnapshots) }}
|
|
||||||
</span>
|
|
||||||
<Toggle
|
|
||||||
id="wizard-show-snapshots"
|
|
||||||
:model-value="ctx.showSnapshots.value"
|
|
||||||
small
|
|
||||||
@update:model-value="
|
|
||||||
(value) => {
|
|
||||||
ctx.showSnapshots.value = !!value
|
|
||||||
void ctx.loadVersions()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="ctx.needsLoaderVersion.value" class="flex min-w-0 flex-col gap-2">
|
|
||||||
<span class="font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.loaderVersion) }}
|
|
||||||
</span>
|
|
||||||
<Combobox
|
|
||||||
:model-value="ctx.selectedLoaderVersion.value"
|
|
||||||
:options="loaderVersionOptions"
|
|
||||||
:placeholder="formatMessage(messages.loaderVersion)"
|
|
||||||
@update:model-value="(value) => (ctx.selectedLoaderVersion.value = value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ServerIcon, UsersIcon } from '@modrinth/assets'
|
import { UsersIcon } from '@modrinth/assets'
|
||||||
import { defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
import { defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
@ -10,49 +10,25 @@ const router = useRouter()
|
|||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'app.multiplayer.title', defaultMessage: 'Multiplayer' },
|
title: { id: 'app.multiplayer.title', defaultMessage: 'Multiplayer' },
|
||||||
serversTab: { id: 'app.multiplayer.tab.servers', defaultMessage: 'Servers' },
|
|
||||||
roomsTab: { id: 'app.multiplayer.tab.rooms', defaultMessage: 'Rooms' },
|
roomsTab: { id: 'app.multiplayer.tab.rooms', defaultMessage: 'Rooms' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeTab = computed(() =>
|
const activeTab = computed(() => (route.path.startsWith('/multiplayer/rooms') ? 'rooms' : 'rooms'))
|
||||||
route.path.startsWith('/multiplayer/rooms') ? 'rooms' : 'servers',
|
|
||||||
)
|
|
||||||
// 服务器详情页用固定高度布局:控制台内部滚动,命令输入框始终可见
|
|
||||||
const isStudioMode = computed(() => route.name === 'MultiplayerServerFileStudio')
|
|
||||||
const isFixedRender = computed(
|
|
||||||
() => route.name === 'MultiplayerServerDetail' || route.name === 'MultiplayerServerFileStudio',
|
|
||||||
)
|
|
||||||
const tabLinks = computed(() => [
|
const tabLinks = computed(() => [
|
||||||
{ label: formatMessage(messages.serversTab), href: '/multiplayer/servers', icon: ServerIcon },
|
|
||||||
{ label: formatMessage(messages.roomsTab), href: '/multiplayer/rooms', icon: UsersIcon },
|
{ label: formatMessage(messages.roomsTab), href: '/multiplayer/rooms', icon: UsersIcon },
|
||||||
])
|
])
|
||||||
|
|
||||||
function handleTabClick(index: number) {
|
function handleTabClick(index: number) {
|
||||||
void router.push(tabLinks.value[index]?.href ?? '/multiplayer/servers')
|
void router.push(tabLinks.value[index]?.href ?? '/multiplayer/rooms')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="box-border flex min-h-full w-full flex-col gap-3 p-6">
|
||||||
:class="
|
<h1 class="m-0 shrink-0 text-2xl font-semibold text-contrast">
|
||||||
isStudioMode
|
{{ formatMessage(messages.title) }}
|
||||||
? 'flex h-full min-h-0 w-full flex-col'
|
</h1>
|
||||||
: isFixedRender
|
<NavTabs mode="local" :active-index="0" :links="tabLinks" @tab-click="handleTabClick" />
|
||||||
? 'box-border flex h-full min-h-0 w-full flex-col gap-3 p-6'
|
|
||||||
: 'box-border flex min-h-full w-full flex-col gap-3 p-6'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template v-if="!isStudioMode">
|
|
||||||
<h1 class="m-0 shrink-0 text-2xl font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.title) }}
|
|
||||||
</h1>
|
|
||||||
<NavTabs
|
|
||||||
mode="local"
|
|
||||||
:active-index="activeTab === 'rooms' ? 1 : 0"
|
|
||||||
:links="tabLinks"
|
|
||||||
@tab-click="handleTabClick"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -103,29 +103,6 @@ export default new createRouter({
|
|||||||
pageTransitionGroup: 'multiplayer',
|
pageTransitionGroup: 'multiplayer',
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
redirect: { name: 'MultiplayerServers' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'servers',
|
|
||||||
name: 'MultiplayerServers',
|
|
||||||
component: () => import('@/components/multiplayer/servers/ServersOverview.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'servers/:id',
|
|
||||||
name: 'MultiplayerServerDetail',
|
|
||||||
component: () => import('@/components/multiplayer/servers/ServerDetail.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'servers/:id/studio',
|
|
||||||
name: 'MultiplayerServerFileStudio',
|
|
||||||
component: () => import('@/components/multiplayer/servers/ServerFileStudio.vue'),
|
|
||||||
meta: {
|
|
||||||
renderMode: 'fixed',
|
|
||||||
breadcrumb: [{ name: 'Multiplayer', link: '/multiplayer/servers' }, { name: 'Studio' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'rooms',
|
path: 'rooms',
|
||||||
name: 'MultiplayerRooms',
|
name: 'MultiplayerRooms',
|
||||||
|
|||||||
Reference in New Issue
Block a user