fix: support concurrent game windows and prepare beta12 GitHub releases

This commit is contained in:
Disy committed 2026-09-25 11:49:41 +08:00
1 parent 91f483b960
commit 1cf67602d8
33 files changed
+681 -293

No files matched your search

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