fix: support concurrent game windows and prepare beta12 GitHub releases
This commit is contained in:
1 parent
91f483b960
commit
1cf67602d8
33 files changed
+681
-293
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "密码"
|
||||
},
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
# The actual version is set by the theseus-build workflow on tagging
|
||||
version = "1.9.6-beta.3"
|
||||
version = "1.0.0-beta12"
|
||||
edition.workspace = true
|
||||
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
|
||||
homepage = "https://www.ghs.red"
|
||||
|
||||
@@ -66,9 +66,7 @@ pub async fn hosted_default() -> Result<theseus::pack::hosted::Publication> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hosted_create(
|
||||
game_dir_root: Option<String>,
|
||||
) -> Result<String> {
|
||||
pub async fn hosted_create(game_dir_root: Option<String>) -> Result<String> {
|
||||
Ok(theseus::pack::hosted::create(game_dir_root).await?)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::http::header::ACCEPT;
|
||||
use tauri::http::HeaderValue;
|
||||
use tauri::http::header::ACCEPT;
|
||||
use tauri::{Manager, ResourceId, Runtime, Webview};
|
||||
use tauri_plugin_http::reqwest;
|
||||
use tauri_plugin_http::reqwest::ClientBuilder;
|
||||
use tauri_plugin_updater::{Error, Update, UpdaterExt};
|
||||
use theseus::{
|
||||
emit_loading, init_loading, launcher_user_agent, LoadingBarType,
|
||||
LoadingBarType, emit_loading, init_loading, launcher_user_agent,
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
use url::Url;
|
||||
|
||||
Reference in new issue
Block a user