重构:移除多人服务器组件及相关逻辑

This commit is contained in:
2026-09-10 22:32:32 +08:00
parent aff1512241
commit 4dae11919f
26 changed files with 9 additions and 4822 deletions

View File

@ -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,

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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
}
}

View File

@ -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'
}

View File

@ -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' },
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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',