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