# Conflicts:
#	.github/workflows/build-on-tag.yml
#	.github/workflows/sync-cnb-source.yml
This commit is contained in:
AxTps committed 2026-09-25 22:29:40 +08:00
commit 6ce39f3a42
43 files changed
+1477 -716

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.0-beta1",
"version": "1.0.0-beta12",
"type": "module",
"scripts": {
"dev": "vite",
@@ -42,6 +42,7 @@ const { offline } = useNetworkStatus()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const messages = defineMessages({
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' },
duplicateInstance: {
@@ -265,7 +266,12 @@ onUnmounted(() => {
@delete="deleteInstance"
/>
<div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row flex flex-col items-start overflow-hidden w-full min-w-full">
<div
v-for="row in actualInstances"
ref="rows"
:key="row.label"
class="row flex flex-col items-start overflow-hidden w-full min-w-full"
>
<HeadingLink class="mt-1" :to="row.route">
{{ row.label }}
</HeadingLink>
@@ -307,7 +313,7 @@ onUnmounted(() => {
</div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stopAll) }} </template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
<template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </template>
@@ -29,6 +29,7 @@ import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
@@ -50,6 +51,8 @@ const handleMinecraftLaunchError = useMinecraftLaunchError()
const { offline } = useNetworkStatus()
const messages = defineMessages({
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
chooseInstance: {
id: 'app.home.minimal.choose-instance',
defaultMessage: 'Choose instance',
@@ -88,14 +91,17 @@ const selectedInstance = computed(() =>
props.instances.find((instance) => instance.id === props.selectedInstanceId),
)
const running = ref(false)
const loading = ref(false)
const currentEvent = ref<string | null>(null)
const installingId = ref<string>()
const installed = computed(() => selectedInstance.value?.install_stage === 'installed')
const installing = computed(
() => selectedInstance.value?.install_stage.includes('installing') ?? false,
)
const busy = computed(
() => loading.value || installing.value || (currentEvent.value === 'launched' && !running.value),
() =>
!!selectedInstance.value &&
(isInstanceLaunching(selectedInstance.value.id) ||
installingId.value === selectedInstance.value.id ||
installing.value),
)
const lastPlayed = computed(() => {
@@ -111,18 +117,17 @@ async function refreshProcessState() {
return
}
const processes = await get_by_instance_id(selectedInstance.value.id).catch((error) => {
const id = selectedInstance.value.id
const processes = await get_by_instance_id(id).catch((error) => {
handleError(error)
return []
})
running.value = processes.length > 0
if (selectedInstance.value?.id === id) running.value = processes.length > 0
}
async function playInstance() {
const instance = selectedInstance.value
if (!instance) return
loading.value = true
if (!instance || busy.value) return
try {
await run(instance.id)
} catch (error) {
@@ -132,7 +137,6 @@ async function playInstance() {
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
} finally {
loading.value = false
await refreshProcessState()
}
}
@@ -142,14 +146,14 @@ async function stopInstance() {
if (!instance) return
await kill(instance.id).catch(handleError)
running.value = false
await refreshProcessState()
}
async function installInstance() {
const instance = selectedInstance.value
if (!instance) return
loading.value = true
installingId.value = instance.id
try {
if (
instance.install_stage !== 'pack_installed' &&
@@ -168,14 +172,14 @@ async function installInstance() {
} catch (error) {
handleError(error)
} finally {
loading.value = false
if (installingId.value === instance.id) installingId.value = undefined
}
}
watch(
() => props.selectedInstanceId,
() => {
currentEvent.value = null
running.value = false
void refreshProcessState()
},
)
@@ -184,9 +188,7 @@ await refreshProcessState()
const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => {
if (event.instance_id !== selectedInstance.value?.id) return
currentEvent.value = event.event
if (event.event === 'finished') running.value = false
else void refreshProcessState()
void refreshProcessState()
})
onUnmounted(() => {
@@ -237,13 +239,17 @@ onUnmounted(() => {
</router-link>
<div class="flex min-h-11 shrink-0 items-center justify-end gap-2">
<ButtonStyled v-if="running" color="red" size="large">
<button class="w-36 justify-center" @click="stopInstance">
<ButtonStyled v-if="running" color="red" size="large" circular>
<button
v-tooltip="formatMessage(messages.stopAll)"
:aria-label="formatMessage(messages.stopAll)"
:disabled="busy"
@click="stopInstance"
>
<StopCircleIcon aria-hidden="true" />
<span class="truncate">{{ formatMessage(commonMessages.stopButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled v-else-if="busy" size="large">
<ButtonStyled v-if="busy" size="large">
<button class="w-36 justify-center" disabled>
<SpinnerIcon class="animate-spin" aria-hidden="true" />
<span class="truncate">
@@ -256,7 +262,9 @@ onUnmounted(() => {
<ButtonStyled v-else-if="installed" color="brand" size="large">
<button class="w-36 justify-center" @click="playInstance">
<PlayIcon class="translate-x-px" aria-hidden="true" />
<span class="truncate">{{ formatMessage(commonMessages.playButton) }}</span>
<span class="truncate">{{
formatMessage(running ? messages.launchAnother : commonMessages.playButton)
}}</span>
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
@@ -137,6 +137,11 @@
<OnlineIndicatorIcon />
<span class="mr-auto text-contrast flex items-center gap-2">
{{ process.instance.name }}
<span class="text-xs text-secondary">{{
formatMessage(messages.windowStarted, {
time: new Date(process.start_time),
})
}}</span>
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
</span>
</button>
@@ -307,6 +312,7 @@ const notificationCenterShown = ref(false)
interface RunningProcess {
uuid: string
start_time: string
instance_id: string
instance: GameInstance
}
@@ -320,6 +326,10 @@ interface LoadingEventPayload {
}
const messages = defineMessages({
windowStarted: {
id: 'app.action-bar.window-started',
defaultMessage: 'Started {time, time, medium}',
},
offline: {
id: 'app.action-bar.offline',
defaultMessage: 'Offline',
@@ -346,7 +356,7 @@ const messages = defineMessages({
},
stopInstance: {
id: 'app.action-bar.stop-instance',
defaultMessage: 'Stop instance',
defaultMessage: 'Stop window',
},
viewLogs: {
id: 'app.action-bar.view-logs',
@@ -401,8 +411,9 @@ const refresh = async () => {
const processes = ((await getRunningProcesses().catch((error) => {
handleError(error)
return []
})) ?? []) as Array<{ uuid: string; instance_id: string }>
const instanceIds = processes.map((process) => process.instance_id)
})) ?? []) as Array<{ uuid: string; instance_id: string; start_time: string }>
processes.sort((a, b) => a.start_time.localeCompare(b.start_time))
const instanceIds = [...new Set(processes.map((process) => process.instance_id))]
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
handleError(error)
return []
@@ -420,9 +431,9 @@ const refresh = async () => {
}
})
.filter((process): process is RunningProcess => process !== null)
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
selectedProcess.value = currentProcesses.value[0]
}
selectedProcess.value =
currentProcesses.value.find((process) => process.uuid === selectedProcess.value?.uuid) ??
currentProcesses.value[0]
}
await refresh()
@@ -1,12 +1,5 @@
<script setup>
import {
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { DownloadIcon, GameIcon, PlayIcon, SpinnerIcon, TimerIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
@@ -28,6 +21,7 @@ import { install_existing_instance, install_pack_to_existing_instance } from '@/
import { kill, run } from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
@@ -36,6 +30,7 @@ const formatRelativeTime = useRelativeTime()
const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const messages = defineMessages({
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' },
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
@@ -85,13 +80,8 @@ const isPlaying = computed(() => props.playing ?? internalPlaying.value)
const displayIcon = computed(() =>
getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader),
)
const loading = ref(false)
const modLoading = computed(
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !isPlaying.value),
)
const loading = computed(() => isInstanceLaunching(props.instance.id))
const modLoading = computed(() => loading.value || installing.value)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
@@ -105,31 +95,26 @@ const checkProcess = async () => {
if (props.playing !== undefined) return
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
internalPlaying.value = runningProcesses.length > 0
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
}
const play = async (e, context) => {
e?.stopPropagation()
loading.value = true
await run(props.instance.id)
.catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id,
instance_name: props.instance.name,
})
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
if (loading.value) return
await run(props.instance.id).catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id,
instance_name: props.instance.name,
})
.finally(() => {
})
loading.value = false
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
await checkProcess()
}
const stop = async (e, context) => {
e?.stopPropagation()
internalPlaying.value = false
await kill(props.instance.id).catch(handleError)
await checkProcess()
}
const repair = async (e) => {
@@ -171,16 +156,11 @@ defineExpose({
instance: props.instance,
})
const currentEvent = ref(null)
const unlisten =
props.playing === undefined
? await process_listener((e) => {
if (e.instance_id === props.instance.id) {
currentEvent.value = e.event
if (e.event === 'finished') {
internalPlaying.value = false
}
void checkProcess()
}
})
: () => undefined
@@ -212,15 +192,7 @@ onUnmounted(() => unlisten())
<span class="line-clamp-2">{{ instance.name }}</span>
</div>
<div class="flex items-center">
<ButtonStyled v-if="isPlaying" color="red" circular @mousehover="checkProcess">
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
@click="(e) => stop(e, 'InstanceCard')"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular>
<ButtonStyled v-if="modLoading" color="standard" circular>
<button v-tooltip="formatMessage(messages.loading)" disabled>
<SpinnerIcon class="animate-spin" />
</button>
@@ -230,7 +202,7 @@ onUnmounted(() => unlisten())
v-tooltip="
offline && !installed
? formatMessage(messages.offlineInstalledOnly)
: formatMessage(commonMessages.playButton)
: formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:disabled="offline && !installed"
@click="(e) => play(e, 'InstanceCard')"
@@ -290,17 +262,8 @@ onUnmounted(() => unlisten())
/>
</div>
<div class="absolute bottom-1.5 right-1.5 flex size-12 items-center justify-center">
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="!modLoading && !installing && !installed"
v-if="!modLoading && !installing && !installed"
size="large"
color="brand"
circular
@@ -324,7 +287,9 @@ onUnmounted(() => unlisten())
</ButtonStyled>
<ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular>
<button
v-tooltip="formatMessage(commonMessages.playButton)"
v-tooltip="
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:class="{
'pointer-events-none scale-75 opacity-0': disabled,
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
@@ -369,19 +334,8 @@ onUnmounted(() => unlisten())
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
:class="{ 'scale-100 opacity-100': isPlaying }"
class="transition-all origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<SpinnerIcon
v-else-if="modLoading || installing"
v-if="modLoading || installing"
v-tooltip="
modLoading
? formatMessage(messages.loading)
@@ -406,7 +360,9 @@ onUnmounted(() => unlisten())
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular>
<button
v-tooltip="formatMessage(commonMessages.playButton)"
v-tooltip="
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
@@ -242,7 +242,7 @@ export const onboardingMessages = defineMessages({
instanceActionsDescription: {
id: 'app.onboarding.instance-actions.description',
defaultMessage:
'Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
'Launch or configure this instance here. You can launch another window while it is running; use the top bar to stop an individual window. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
},
instanceTabsTitle: {
id: 'app.onboarding.instance-tabs.title',
@@ -30,6 +30,7 @@ import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils'
import { handleSevereError } from '@/store/error'
@@ -46,10 +47,8 @@ const messages = defineMessages({
defaultMessage: 'Loading modpack...',
},
viewInstance: { id: 'app.instance-item.view-instance', defaultMessage: 'View instance' },
alreadyOpen: {
id: 'app.instance-item.already-open',
defaultMessage: 'Instance is already open',
},
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
})
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
@@ -91,33 +90,32 @@ const loader = computed(() => {
}
})
const loading = ref(false)
const stopping = ref(false)
const loading = computed(() => stopping.value || isInstanceLaunching(props.instance.id))
const internalPlaying = ref(false)
const isPlaying = computed(() => props.playing ?? internalPlaying.value)
const play = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
await run(props.instance.id)
.catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id,
instance_name: props.instance.name,
})
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
.finally(() => {
if (loading.value) return
await run(props.instance.id).catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id,
instance_name: props.instance.name,
})
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
emit('play')
loading.value = false
await checkProcess()
}
const stop = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
stopping.value = true
await kill(props.instance.id).catch(handleError)
emit('stop')
loading.value = false
stopping.value = false
await checkProcess()
}
const unlistenProcesses =
@@ -131,7 +129,7 @@ const checkProcess = async () => {
if (props.playing !== undefined) return
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
internalPlaying.value = runningProcesses.length > 0
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
}
onMounted(() => {
@@ -155,7 +153,8 @@ onUnmounted(() => {
:class="[
flat ? 'px-2 py-2 hover:bg-button-bg' : 'card-shadow bg-bg-raised p-3',
{
'instance-item-dashboard-compact grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 p-1.5': dashboardDensity === 'compact',
'instance-item-dashboard-compact grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 p-1.5':
dashboardDensity === 'compact',
'instance-item-dashboard-comfortable p-2': dashboardDensity === 'comfortable',
},
]"
@@ -214,23 +213,25 @@ onUnmounted(() => {
color="red"
:circular="dashboardDensity === 'compact'"
>
<button @click="stop">
<button v-tooltip="formatMessage(messages.stopAll)" @click="stop">
<StopCircleIcon aria-hidden="true" />
<span v-if="dashboardDensity !== 'compact'">
{{ formatMessage(commonMessages.stopButton) }}
{{ formatMessage(messages.stopAll) }}
</span>
</button>
</ButtonStyled>
<ButtonStyled v-else :circular="dashboardDensity === 'compact'">
<ButtonStyled :circular="dashboardDensity === 'compact'">
<button
v-tooltip="isPlaying ? formatMessage(messages.alreadyOpen) : null"
:disabled="isPlaying || loading"
v-tooltip="
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:disabled="loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
<span v-if="dashboardDensity !== 'compact'">
{{ formatMessage(commonMessages.playButton) }}
{{ formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton) }}
</span>
</button>
</ButtonStyled>
@@ -280,5 +281,4 @@ onUnmounted(() => {
.instance-item-dashboard-compact > :last-child {
gap: 0.125rem;
}
</style>
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { isInstanceLaunching, launchOnce } from './instance-launch-state.ts'
test('launches different instances concurrently and coalesces repeated pending clicks', async () => {
let finishA!: (value: string) => void
let starts = 0
const first = launchOnce('a', () => {
starts++
return new Promise<string>((resolve) => {
finishA = resolve
})
})
assert.equal(
launchOnce('a', async () => 'unexpected duplicate'),
first,
)
assert.equal(isInstanceLaunching('a'), true)
assert.equal(isInstanceLaunching('b'), false)
assert.equal(await launchOnce('b', async () => 'b started'), 'b started')
assert.equal(isInstanceLaunching('a'), true)
finishA('a started')
assert.equal(await first, 'a started')
assert.equal(starts, 1)
assert.equal(isInstanceLaunching('a'), false)
assert.equal(await launchOnce('a', async () => 'second window'), 'second window')
})
test('failed launch releases only its own pending state and can retry', async () => {
await assert.rejects(
launchOnce('failed', async () => {
throw new Error('launch failed')
}),
/launch failed/,
)
assert.equal(isInstanceLaunching('failed'), false)
assert.equal(await launchOnce('failed', async () => 'retry'), 'retry')
})
@@ -0,0 +1,20 @@
import { reactive } from 'vue'
const pending = reactive(new Map<string, Promise<unknown>>())
export function isInstanceLaunching(instanceId: string) {
return pending.has(instanceId)
}
/** Coalesce repeated clicks for one launch without blocking other instances. */
export function launchOnce<T>(instanceId: string, launch: () => Promise<T>): Promise<T> {
const existing = pending.get(instanceId)
if (existing) return existing as Promise<T>
const task = Promise.resolve()
.then(launch)
.finally(() => {
pending.delete(instanceId)
})
pending.set(instanceId, task)
return task
}
+10 -2
View File
@@ -19,6 +19,7 @@ import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { launchOnce } from '@/helpers/instance-launch-state'
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
import { get_jre, get_memory_status } from '@/helpers/jre.js'
import { get as getSettings } from '@/helpers/settings'
@@ -1071,12 +1072,19 @@ export async function resolveGcLaunchIntent(
// Run Minecraft using an instance
// Returns the process metadata plus an optional GC fallback report.
export async function run(
export function run(
instanceId: string,
serverAddress: string | null = null,
): Promise<InstanceRunResult> {
return launchOnce(instanceId, () => launchInstance(instanceId, serverAddress))
}
async function launchInstance(
instanceId: string,
serverAddress: string | null,
): Promise<InstanceRunResult> {
await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
if ((await getInstanceMode(instanceId)) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {
instanceId,
+11 -2
View File
@@ -348,7 +348,7 @@
"message": "Show more running instances"
},
"app.action-bar.stop-instance": {
"message": "Stop instance"
"message": "Stop window"
},
"app.action-bar.update": {
"message": "Update"
@@ -2963,6 +2963,15 @@
"app.instance.launch-instance": {
"message": "Launch instance"
},
"app.instance.launch-another": {
"message": "Launch another window"
},
"app.action-bar.window-started": {
"message": "Started {time, time, medium}"
},
"app.instance.stop-all": {
"message": "Stop all windows of this instance"
},
"app.instance.linked-to": {
"message": "Linked to"
},
@@ -5889,7 +5898,7 @@
"message": "Switch your home"
},
"app.onboarding.instance-actions.description": {
"message": "Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings."
"message": "Launch or configure this instance here. You can launch another window while it is running; use the top bar to stop an individual window. Choose a player on first launch; the instance remembers your choice until you switch it in settings."
},
"app.onboarding.instance-actions.title": {
"message": "The main controls"
+14 -2
View File
@@ -380,7 +380,7 @@
"message": "显示更多运行中的实例"
},
"app.action-bar.stop-instance": {
"message": "停止实例"
"message": "停止当前窗口"
},
"app.action-bar.update": {
"message": "更新"
@@ -3049,6 +3049,15 @@
"app.instance.launch-instance": {
"message": "启动实例"
},
"app.instance.launch-another": {
"message": "再开一个窗口"
},
"app.action-bar.window-started": {
"message": "{time, time, medium} 启动"
},
"app.instance.stop-all": {
"message": "停止此实例全部窗口"
},
"app.instance.linked-to": {
"message": "关联到"
},
@@ -5975,7 +5984,7 @@
"message": "切换主页样式"
},
"app.onboarding.instance-actions.description": {
"message": "从这里启动或配置实例。首次启动选择玩家后会自动记住,之后可在实例设置中切换。"
"message": "从这里启动或配置实例。运行中可再开一个窗口,顶部运行列表可单独停止某个窗口。首次启动选择玩家后会自动记住,之后可在实例设置中切换。"
},
"app.onboarding.instance-actions.title": {
"message": "主操作台"
@@ -10288,6 +10297,9 @@
"minecraft-account.third-party-modal.description": {
"message": "使用 StarLight 皮肤站账号登录。"
},
"minecraft-account.third-party-modal.littleskin": {
"message": "使用 LittleSkin"
},
"minecraft-account.third-party-modal.password": {
"message": "密码"
},
+55 -54
View File
@@ -156,34 +156,23 @@
{{ formatMessage(commonMessages.repairButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button :disabled="stopping" @click="stopInstance('InstancePage')">
<StopCircleIcon />
{{
stopping
? formatMessage(messages.stopping)
: formatMessage(commonMessages.stopButton)
}}
<ButtonStyled v-else-if="loading" color="brand" size="large">
<button disabled>
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.starting, { seconds: launchElapsedSeconds }) }}
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false && !isServerInstance"
color="brand"
size="large"
>
<ButtonStyled v-else-if="!isServerInstance" color="brand" size="large">
<button @click="startInstance('InstancePage')">
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
{{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }}
</button>
</ButtonStyled>
<div
v-else-if="playing === false && loading === false && isServerInstance"
class="joined-buttons"
>
<div v-else class="joined-buttons">
<ButtonStyled color="brand" size="large">
<button @click="handlePlayServer()">
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
{{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand" size="large">
@@ -214,14 +203,10 @@
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled
v-else-if="loading === true && playing === false"
color="brand"
size="large"
>
<button disabled>
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.starting, { seconds: launchElapsedSeconds }) }}
<ButtonStyled v-if="playing" color="red" size="large">
<button :disabled="stopping || loading" @click="stopInstance('InstancePage')">
<StopCircleIcon />
{{ formatMessage(stopping ? messages.stopping : messages.stopAll) }}
</button>
</ButtonStyled>
<ButtonStyled circular size="large">
@@ -317,7 +302,7 @@
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stopAll) }} </template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EditIcon /> {{ formatMessage(commonMessages.editButton) }} </template>
<template #copy_path> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
@@ -437,6 +422,7 @@ import {
} from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
@@ -459,6 +445,8 @@ const props = defineProps<{
const { formatMessage } = useVIntl()
const messages = defineMessages({
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
upgradedTo: {
id: 'app.instance.post-upgrade-status',
@@ -520,7 +508,10 @@ const postUpgradeNoticeQuery = usePostUpgradeNotice(() => instance.value?.id ??
const postUpgradeNotice = computed(() => postUpgradeNoticeQuery.data.value ?? null)
const symlinkWarning = useSymlinkWarningDismiss(instanceId)
const playing = ref(false)
const loading = ref(false)
const serverLaunchingId = ref<string>()
const loading = computed(
() => isInstanceLaunching(props.id) || serverLaunchingId.value === props.id,
)
const launchElapsedSeconds = ref(0)
const subpagePending = ref(false)
const stopping = ref(false)
@@ -655,9 +646,10 @@ function fetchDeferredData(instanceId?: string) {
}
async function updatePlayState() {
const runningProcesses = await get_by_instance_id(props.id).catch(handleError)
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
const id = props.id
const runningProcesses = await get_by_instance_id(id).catch(handleError)
if (props.id === id && Array.isArray(runningProcesses))
playing.value = runningProcesses.length > 0
}
await fetchInstance()
@@ -665,6 +657,7 @@ watch(
() => props.id,
async () => {
if (route.path.startsWith('/instance')) {
playing.value = false
await fetchInstance()
}
},
@@ -741,19 +734,16 @@ watch(
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {
if (!instance.value) return
if (!offline.value && updateToPlayModal.value?.hasUpdate) {
if (!instance.value || loading.value) return
const id = props.id
const name = instance.value.name
if (!playing.value && !offline.value && updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value)
return
}
loading.value = true
launchElapsedSeconds.value = 0
launchElapsedTimer = setInterval(() => {
launchElapsedSeconds.value += 1
}, 1000)
try {
const result = await run(props.id)
const result = await run(id)
const gcNotice = result.gc_notice
if (gcNotice && gcReportFellBack(gcNotice)) {
addNotification({
@@ -764,39 +754,50 @@ const startInstance = async (context: string) => {
}),
})
}
playing.value = true
await updatePlayState()
} catch (err) {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.id,
instance_name: instance.value.name,
instance_id: id,
instance_name: name,
})
if (!handled) {
handleSevereError(err, { instanceId: props.id })
handleSevereError(err, { instanceId: id })
}
} finally {
clearInterval(launchElapsedTimer)
launchElapsedTimer = undefined
loading.value = false
}
}
watch(
[loading, () => props.id],
([busy]) => {
clearInterval(launchElapsedTimer)
launchElapsedSeconds.value = 0
launchElapsedTimer = busy
? setInterval(() => {
launchElapsedSeconds.value += 1
}, 1000)
: undefined
},
{ immediate: true },
)
const stopInstance = async (context: string) => {
stopping.value = true
await kill(props.id).catch(handleError)
stopping.value = false
playing.value = false
await updatePlayState()
if (!instance.value) return
}
const handlePlayServer = async () => {
if (!instance.value?.link?.project_id) return
loading.value = true
if (!instance.value?.link?.project_id || loading.value) return
const id = props.id
serverLaunchingId.value = id
try {
await playServerProject(instance.value.link.project_id)
} finally {
await updatePlayState()
loading.value = false
if (serverLaunchingId.value === id) serverLaunchingId.value = undefined
}
}
@@ -933,8 +934,8 @@ const unlistenInstances = await instance_listener(
)
const unlistenProcesses = await process_listener((e: { event: string; instance_id: string }) => {
if (e.event === 'finished' && e.instance_id === props.id) {
playing.value = false
if (e.instance_id === props.id) {
void updatePlayState()
void queryClient.invalidateQueries({ queryKey: postUpgradeNoticeQueryKey(props.id) })
}
})