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
+665 -277

No files matched your search

+29 -4
View File
@@ -1,4 +1,4 @@
name: Axolotl release name: Starlight release
on: on:
push: push:
@@ -53,6 +53,15 @@ jobs:
node scripts/axolotl/i18n-check.mjs node scripts/axolotl/i18n-check.mjs
cargo fmt --all --check cargo fmt --all --check
- name: Check updater signing configuration
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]; then
echo '::error::Configure TAURI_SIGNING_PRIVATE_KEY with the private key matching apps/app/tauri-release.conf.json before publishing updates.'
exit 1
fi
- name: Prepare draft release - name: Prepare draft release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -72,7 +81,7 @@ jobs:
release_args=( release_args=(
--draft --draft
--notes-file "$release_notes" --notes-file "$release_notes"
--title "Axolotl Launcher ${GITHUB_REF_NAME}" --title "Starlight Launcher ${GITHUB_REF_NAME}"
) )
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
release_args+=(--prerelease) release_args+=(--prerelease)
@@ -175,7 +184,7 @@ jobs:
tauriScript: pnpm tauri tauriScript: pnpm tauri
args: ${{ matrix.args }} args: ${{ matrix.args }}
tagName: ${{ github.ref_name }} tagName: ${{ github.ref_name }}
releaseName: Axolotl Launcher ${{ github.ref_name }} releaseName: Starlight Launcher ${{ github.ref_name }}
releaseDraft: true releaseDraft: true
prerelease: ${{ contains(github.ref_name, '-') }} prerelease: ${{ contains(github.ref_name, '-') }}
generateReleaseNotes: false generateReleaseNotes: false
@@ -277,12 +286,17 @@ jobs:
name: Verify and publish name: Verify and publish
needs: build needs: build
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
env:
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
steps: steps:
- name: Check out source - name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main ref: ${{ github.ref }}
- name: Download updater signatures - name: Download updater signatures
env: env:
@@ -349,6 +363,7 @@ jobs:
node scripts/axolotl/create-update-server-catalog.test.mjs node scripts/axolotl/create-update-server-catalog.test.mjs
- name: Notify Update Server to publish release assets - name: Notify Update Server to publish release assets
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
env: env:
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }} UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }} UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
@@ -359,6 +374,13 @@ jobs:
release-check/release.json \ release-check/release.json \
"${GITHUB_REF_NAME}" "${GITHUB_REF_NAME}"
- name: Check out website branch
if: ${{ !contains(github.ref_name, '-') }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: main
clean: false
- name: Publish website download metadata - name: Publish website download metadata
if: ${{ !contains(github.ref_name, '-') }} if: ${{ !contains(github.ref_name, '-') }}
env: env:
@@ -381,11 +403,14 @@ jobs:
fi fi
- name: Mirror published release to CNB - name: Mirror published release to CNB
if: ${{ env.CNB_TOKEN != '' && env.CNB_REPO_URL_HTTPS != '' }}
env: env:
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }} CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }} CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
CNB_TOKEN_USER_NAME: ${{ secrets.CNB_TOKEN_USER_NAME }} CNB_TOKEN_USER_NAME: ${{ secrets.CNB_TOKEN_USER_NAME }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_API_BASE_URL: https://api.github.com/repos/${{ github.repository }}
GITHUB_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -z "${CNB_TOKEN:-}" ]]; then if [[ -z "${CNB_TOKEN:-}" ]]; then
+3 -2
View File
@@ -19,6 +19,7 @@ concurrency:
jobs: jobs:
sync: sync:
name: Sync Git ref name: Sync Git ref
if: ${{ vars.ENABLE_CNB_SYNC == 'true' }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
@@ -38,8 +39,8 @@ jobs:
DELETED_REF_TYPE: ${{ github.event.ref_type }} DELETED_REF_TYPE: ${{ github.event.ref_type }}
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -z "${CNB_TOKEN:-}" ]]; then if [[ -z "${CNB_TOKEN:-}" || -z "${CNB_REPO_URL_HTTPS:-}" ]]; then
echo "CNB_TOKEN is not configured" echo "CNB_TOKEN and CNB_REPO_URL_HTTPS must be configured"
exit 1 exit 1
fi fi
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.0-beta1", "version": "1.0.0-beta12",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -42,6 +42,7 @@ const { offline } = useNetworkStatus()
const handleMinecraftLaunchError = useMinecraftLaunchError() const handleMinecraftLaunchError = useMinecraftLaunchError()
const messages = defineMessages({ 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' }, addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' }, viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' },
duplicateInstance: { duplicateInstance: {
@@ -265,7 +266,12 @@ onUnmounted(() => {
@delete="deleteInstance" @delete="deleteInstance"
/> />
<div ref="rowContainer" class="flex flex-col gap-4"> <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"> <HeadingLink class="mt-1" :to="row.route">
{{ row.label }} {{ row.label }}
</HeadingLink> </HeadingLink>
@@ -307,7 +313,7 @@ onUnmounted(() => {
</div> </div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick"> <ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template> <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 #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template> <template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
<template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </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 { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error' import { handleSevereError } from '@/store/error'
@@ -50,6 +51,8 @@ const handleMinecraftLaunchError = useMinecraftLaunchError()
const { offline } = useNetworkStatus() const { offline } = useNetworkStatus()
const messages = defineMessages({ 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: { chooseInstance: {
id: 'app.home.minimal.choose-instance', id: 'app.home.minimal.choose-instance',
defaultMessage: 'Choose instance', defaultMessage: 'Choose instance',
@@ -88,14 +91,17 @@ const selectedInstance = computed(() =>
props.instances.find((instance) => instance.id === props.selectedInstanceId), props.instances.find((instance) => instance.id === props.selectedInstanceId),
) )
const running = ref(false) const running = ref(false)
const loading = ref(false) const installingId = ref<string>()
const currentEvent = ref<string | null>(null)
const installed = computed(() => selectedInstance.value?.install_stage === 'installed') const installed = computed(() => selectedInstance.value?.install_stage === 'installed')
const installing = computed( const installing = computed(
() => selectedInstance.value?.install_stage.includes('installing') ?? false, () => selectedInstance.value?.install_stage.includes('installing') ?? false,
) )
const busy = computed( 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(() => { const lastPlayed = computed(() => {
@@ -111,18 +117,17 @@ async function refreshProcessState() {
return 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) handleError(error)
return [] return []
}) })
running.value = processes.length > 0 if (selectedInstance.value?.id === id) running.value = processes.length > 0
} }
async function playInstance() { async function playInstance() {
const instance = selectedInstance.value const instance = selectedInstance.value
if (!instance) return if (!instance || busy.value) return
loading.value = true
try { try {
await run(instance.id) await run(instance.id)
} catch (error) { } catch (error) {
@@ -132,7 +137,6 @@ async function playInstance() {
}) })
if (!handled) handleSevereError(error, { instanceId: instance.id }) if (!handled) handleSevereError(error, { instanceId: instance.id })
} finally { } finally {
loading.value = false
await refreshProcessState() await refreshProcessState()
} }
} }
@@ -142,14 +146,14 @@ async function stopInstance() {
if (!instance) return if (!instance) return
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
running.value = false await refreshProcessState()
} }
async function installInstance() { async function installInstance() {
const instance = selectedInstance.value const instance = selectedInstance.value
if (!instance) return if (!instance) return
loading.value = true installingId.value = instance.id
try { try {
if ( if (
instance.install_stage !== 'pack_installed' && instance.install_stage !== 'pack_installed' &&
@@ -168,14 +172,14 @@ async function installInstance() {
} catch (error) { } catch (error) {
handleError(error) handleError(error)
} finally { } finally {
loading.value = false if (installingId.value === instance.id) installingId.value = undefined
} }
} }
watch( watch(
() => props.selectedInstanceId, () => props.selectedInstanceId,
() => { () => {
currentEvent.value = null running.value = false
void refreshProcessState() void refreshProcessState()
}, },
) )
@@ -184,9 +188,7 @@ await refreshProcessState()
const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => { const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => {
if (event.instance_id !== selectedInstance.value?.id) return if (event.instance_id !== selectedInstance.value?.id) return
currentEvent.value = event.event void refreshProcessState()
if (event.event === 'finished') running.value = false
else void refreshProcessState()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -237,13 +239,17 @@ onUnmounted(() => {
</router-link> </router-link>
<div class="flex min-h-11 shrink-0 items-center justify-end gap-2"> <div class="flex min-h-11 shrink-0 items-center justify-end gap-2">
<ButtonStyled v-if="running" color="red" size="large"> <ButtonStyled v-if="running" color="red" size="large" circular>
<button class="w-36 justify-center" @click="stopInstance"> <button
v-tooltip="formatMessage(messages.stopAll)"
:aria-label="formatMessage(messages.stopAll)"
:disabled="busy"
@click="stopInstance"
>
<StopCircleIcon aria-hidden="true" /> <StopCircleIcon aria-hidden="true" />
<span class="truncate">{{ formatMessage(commonMessages.stopButton) }}</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else-if="busy" size="large"> <ButtonStyled v-if="busy" size="large">
<button class="w-36 justify-center" disabled> <button class="w-36 justify-center" disabled>
<SpinnerIcon class="animate-spin" aria-hidden="true" /> <SpinnerIcon class="animate-spin" aria-hidden="true" />
<span class="truncate"> <span class="truncate">
@@ -256,7 +262,9 @@ onUnmounted(() => {
<ButtonStyled v-else-if="installed" color="brand" size="large"> <ButtonStyled v-else-if="installed" color="brand" size="large">
<button class="w-36 justify-center" @click="playInstance"> <button class="w-36 justify-center" @click="playInstance">
<PlayIcon class="translate-x-px" aria-hidden="true" /> <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> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else color="brand" size="large"> <ButtonStyled v-else color="brand" size="large">
@@ -137,6 +137,11 @@
<OnlineIndicatorIcon /> <OnlineIndicatorIcon />
<span class="mr-auto text-contrast flex items-center gap-2"> <span class="mr-auto text-contrast flex items-center gap-2">
{{ process.instance.name }} {{ 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" /> <StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
</span> </span>
</button> </button>
@@ -307,6 +312,7 @@ const notificationCenterShown = ref(false)
interface RunningProcess { interface RunningProcess {
uuid: string uuid: string
start_time: string
instance_id: string instance_id: string
instance: GameInstance instance: GameInstance
} }
@@ -320,6 +326,10 @@ interface LoadingEventPayload {
} }
const messages = defineMessages({ const messages = defineMessages({
windowStarted: {
id: 'app.action-bar.window-started',
defaultMessage: 'Started {time, time, medium}',
},
offline: { offline: {
id: 'app.action-bar.offline', id: 'app.action-bar.offline',
defaultMessage: 'Offline', defaultMessage: 'Offline',
@@ -346,7 +356,7 @@ const messages = defineMessages({
}, },
stopInstance: { stopInstance: {
id: 'app.action-bar.stop-instance', id: 'app.action-bar.stop-instance',
defaultMessage: 'Stop instance', defaultMessage: 'Stop window',
}, },
viewLogs: { viewLogs: {
id: 'app.action-bar.view-logs', id: 'app.action-bar.view-logs',
@@ -401,8 +411,9 @@ const refresh = async () => {
const processes = ((await getRunningProcesses().catch((error) => { const processes = ((await getRunningProcesses().catch((error) => {
handleError(error) handleError(error)
return [] return []
})) ?? []) as Array<{ uuid: string; instance_id: string }> })) ?? []) as Array<{ uuid: string; instance_id: string; start_time: string }>
const instanceIds = processes.map((process) => process.instance_id) 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) => { const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
handleError(error) handleError(error)
return [] return []
@@ -420,9 +431,9 @@ const refresh = async () => {
} }
}) })
.filter((process): process is RunningProcess => process !== null) .filter((process): process is RunningProcess => process !== null)
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) { selectedProcess.value =
selectedProcess.value = currentProcesses.value[0] currentProcesses.value.find((process) => process.uuid === selectedProcess.value?.uuid) ??
} currentProcesses.value[0]
} }
await refresh() await refresh()
@@ -1,12 +1,5 @@
<script setup> <script setup>
import { import { DownloadIcon, GameIcon, PlayIcon, SpinnerIcon, TimerIcon } from '@modrinth/assets'
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, ButtonStyled,
@@ -28,6 +21,7 @@ import { install_existing_instance, install_pack_to_existing_instance } from '@/
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons' import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { showInstanceInFolder } from '@/helpers/utils.js' import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
@@ -36,6 +30,7 @@ const formatRelativeTime = useRelativeTime()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError() const handleMinecraftLaunchError = useMinecraftLaunchError()
const messages = defineMessages({ const messages = defineMessages({
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' }, loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' },
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' }, played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' }, neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
@@ -85,13 +80,8 @@ const isPlaying = computed(() => props.playing ?? internalPlaying.value)
const displayIcon = computed(() => const displayIcon = computed(() =>
getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader), getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader),
) )
const loading = ref(false) const loading = computed(() => isInstanceLaunching(props.instance.id))
const modLoading = computed( const modLoading = computed(() => loading.value || installing.value)
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !isPlaying.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing')) const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed') const installed = computed(() => props.instance.install_stage === 'installed')
@@ -105,31 +95,26 @@ const checkProcess = async () => {
if (props.playing !== undefined) return if (props.playing !== undefined) return
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError) 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) => { const play = async (e, context) => {
e?.stopPropagation() e?.stopPropagation()
loading.value = true if (loading.value) return
await run(props.instance.id) await run(props.instance.id).catch(async (err) => {
.catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, { const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id, instance_id: props.instance.id,
instance_name: props.instance.name, instance_name: props.instance.name,
}) })
if (!handled) handleSevereError(err, { instanceId: props.instance.id }) if (!handled) handleSevereError(err, { instanceId: props.instance.id })
}) })
.finally(() => { await checkProcess()
})
loading.value = false
} }
const stop = async (e, context) => { const stop = async (e, context) => {
e?.stopPropagation() e?.stopPropagation()
internalPlaying.value = false
await kill(props.instance.id).catch(handleError) await kill(props.instance.id).catch(handleError)
await checkProcess()
} }
const repair = async (e) => { const repair = async (e) => {
@@ -171,16 +156,11 @@ defineExpose({
instance: props.instance, instance: props.instance,
}) })
const currentEvent = ref(null)
const unlisten = const unlisten =
props.playing === undefined props.playing === undefined
? await process_listener((e) => { ? await process_listener((e) => {
if (e.instance_id === props.instance.id) { if (e.instance_id === props.instance.id) {
currentEvent.value = e.event void checkProcess()
if (e.event === 'finished') {
internalPlaying.value = false
}
} }
}) })
: () => undefined : () => undefined
@@ -212,15 +192,7 @@ onUnmounted(() => unlisten())
<span class="line-clamp-2">{{ instance.name }}</span> <span class="line-clamp-2">{{ instance.name }}</span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<ButtonStyled v-if="isPlaying" color="red" circular @mousehover="checkProcess"> <ButtonStyled v-if="modLoading" color="standard" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
@click="(e) => stop(e, 'InstanceCard')"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="formatMessage(messages.loading)" disabled> <button v-tooltip="formatMessage(messages.loading)" disabled>
<SpinnerIcon class="animate-spin" /> <SpinnerIcon class="animate-spin" />
</button> </button>
@@ -230,7 +202,7 @@ onUnmounted(() => unlisten())
v-tooltip=" v-tooltip="
offline && !installed offline && !installed
? formatMessage(messages.offlineInstalledOnly) ? formatMessage(messages.offlineInstalledOnly)
: formatMessage(commonMessages.playButton) : formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
" "
:disabled="offline && !installed" :disabled="offline && !installed"
@click="(e) => play(e, 'InstanceCard')" @click="(e) => play(e, 'InstanceCard')"
@@ -290,17 +262,8 @@ onUnmounted(() => unlisten())
/> />
</div> </div>
<div class="absolute bottom-1.5 right-1.5 flex size-12 items-center justify-center"> <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 <ButtonStyled
v-else-if="!modLoading && !installing && !installed" v-if="!modLoading && !installing && !installed"
size="large" size="large"
color="brand" color="brand"
circular circular
@@ -324,7 +287,9 @@ onUnmounted(() => unlisten())
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular> <ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular>
<button <button
v-tooltip="formatMessage(commonMessages.playButton)" v-tooltip="
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:class="{ :class="{
'pointer-events-none scale-75 opacity-0': disabled, 'pointer-events-none scale-75 opacity-0': disabled,
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100': '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`}`" :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"> <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 <SpinnerIcon
v-else-if="modLoading || installing" v-if="modLoading || installing"
v-tooltip=" v-tooltip="
modLoading modLoading
? formatMessage(messages.loading) ? formatMessage(messages.loading)
@@ -406,7 +360,9 @@ onUnmounted(() => unlisten())
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular> <ButtonStyled v-else size="large" color="brand" circular>
<button <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'}`" :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')" @click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess" @mousehover="checkProcess"
@@ -242,7 +242,7 @@ export const onboardingMessages = defineMessages({
instanceActionsDescription: { instanceActionsDescription: {
id: 'app.onboarding.instance-actions.description', id: 'app.onboarding.instance-actions.description',
defaultMessage: 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: { instanceTabsTitle: {
id: 'app.onboarding.instance-tabs.title', id: 'app.onboarding.instance-tabs.title',
@@ -30,6 +30,7 @@ import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils' import { showInstanceInFolder } from '@/helpers/utils'
import { handleSevereError } from '@/store/error' import { handleSevereError } from '@/store/error'
@@ -46,10 +47,8 @@ const messages = defineMessages({
defaultMessage: 'Loading modpack...', defaultMessage: 'Loading modpack...',
}, },
viewInstance: { id: 'app.instance-item.view-instance', defaultMessage: 'View instance' }, viewInstance: { id: 'app.instance-item.view-instance', defaultMessage: 'View instance' },
alreadyOpen: { launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
id: 'app.instance-item.already-open', stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
defaultMessage: 'Instance is already open',
},
}) })
const formatDateTime = useFormatDateTime({ const formatDateTime = useFormatDateTime({
timeStyle: 'short', 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 internalPlaying = ref(false)
const isPlaying = computed(() => props.playing ?? internalPlaying.value) const isPlaying = computed(() => props.playing ?? internalPlaying.value)
const play = async (event: MouseEvent) => { const play = async (event: MouseEvent) => {
event?.stopPropagation() event?.stopPropagation()
loading.value = true if (loading.value) return
await run(props.instance.id) await run(props.instance.id).catch(async (err) => {
.catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, { const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id, instance_id: props.instance.id,
instance_name: props.instance.name, instance_name: props.instance.name,
}) })
if (!handled) handleSevereError(err, { instanceId: props.instance.id }) if (!handled) handleSevereError(err, { instanceId: props.instance.id })
}) })
.finally(() => {
})
emit('play') emit('play')
loading.value = false await checkProcess()
} }
const stop = async (event: MouseEvent) => { const stop = async (event: MouseEvent) => {
event?.stopPropagation() event?.stopPropagation()
loading.value = true stopping.value = true
await kill(props.instance.id).catch(handleError) await kill(props.instance.id).catch(handleError)
emit('stop') emit('stop')
loading.value = false stopping.value = false
await checkProcess()
} }
const unlistenProcesses = const unlistenProcesses =
@@ -131,7 +129,7 @@ const checkProcess = async () => {
if (props.playing !== undefined) return if (props.playing !== undefined) return
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError) 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(() => { onMounted(() => {
@@ -155,7 +153,8 @@ onUnmounted(() => {
:class="[ :class="[
flat ? 'px-2 py-2 hover:bg-button-bg' : 'card-shadow bg-bg-raised p-3', 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', 'instance-item-dashboard-comfortable p-2': dashboardDensity === 'comfortable',
}, },
]" ]"
@@ -214,23 +213,25 @@ onUnmounted(() => {
color="red" color="red"
:circular="dashboardDensity === 'compact'" :circular="dashboardDensity === 'compact'"
> >
<button @click="stop"> <button v-tooltip="formatMessage(messages.stopAll)" @click="stop">
<StopCircleIcon aria-hidden="true" /> <StopCircleIcon aria-hidden="true" />
<span v-if="dashboardDensity !== 'compact'"> <span v-if="dashboardDensity !== 'compact'">
{{ formatMessage(commonMessages.stopButton) }} {{ formatMessage(messages.stopAll) }}
</span> </span>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else :circular="dashboardDensity === 'compact'"> <ButtonStyled :circular="dashboardDensity === 'compact'">
<button <button
v-tooltip="isPlaying ? formatMessage(messages.alreadyOpen) : null" v-tooltip="
:disabled="isPlaying || loading" formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:disabled="loading"
@click="play" @click="play"
> >
<SpinnerIcon v-if="loading" class="animate-spin" /> <SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" /> <PlayIcon v-else aria-hidden="true" />
<span v-if="dashboardDensity !== 'compact'"> <span v-if="dashboardDensity !== 'compact'">
{{ formatMessage(commonMessages.playButton) }} {{ formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton) }}
</span> </span>
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -280,5 +281,4 @@ onUnmounted(() => {
.instance-item-dashboard-compact > :last-child { .instance-item-dashboard-compact > :last-child {
gap: 0.125rem; gap: 0.125rem;
} }
</style> </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 { setLastGcLaunchReport } from '@/helpers/gc-notice'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs' import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { prepareInstancePlayer } from '@/helpers/instance-player' import { prepareInstancePlayer } from '@/helpers/instance-player'
import { launchOnce } from '@/helpers/instance-launch-state'
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments' import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
import { get_jre, get_memory_status } from '@/helpers/jre.js' import { get_jre, get_memory_status } from '@/helpers/jre.js'
import { get as getSettings } from '@/helpers/settings' import { get as getSettings } from '@/helpers/settings'
@@ -1071,12 +1072,19 @@ export async function resolveGcLaunchIntent(
// Run Minecraft using an instance // Run Minecraft using an instance
// Returns the process metadata plus an optional GC fallback report. // Returns the process metadata plus an optional GC fallback report.
export async function run( export function run(
instanceId: string, instanceId: string,
serverAddress: string | null = null, serverAddress: string | null = null,
): Promise<InstanceRunResult> {
return launchOnce(instanceId, () => launchInstance(instanceId, serverAddress))
}
async function launchInstance(
instanceId: string,
serverAddress: string | null,
): Promise<InstanceRunResult> { ): Promise<InstanceRunResult> {
await prepareInstancePlayer(instanceId) await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession() if ((await getInstanceMode(instanceId)) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId) const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', { const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {
instanceId, instanceId,
+11 -2
View File
@@ -348,7 +348,7 @@
"message": "Show more running instances" "message": "Show more running instances"
}, },
"app.action-bar.stop-instance": { "app.action-bar.stop-instance": {
"message": "Stop instance" "message": "Stop window"
}, },
"app.action-bar.update": { "app.action-bar.update": {
"message": "Update" "message": "Update"
@@ -2963,6 +2963,15 @@
"app.instance.launch-instance": { "app.instance.launch-instance": {
"message": "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": { "app.instance.linked-to": {
"message": "Linked to" "message": "Linked to"
}, },
@@ -5889,7 +5898,7 @@
"message": "Switch your home" "message": "Switch your home"
}, },
"app.onboarding.instance-actions.description": { "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": { "app.onboarding.instance-actions.title": {
"message": "The main controls" "message": "The main controls"
+14 -2
View File
@@ -380,7 +380,7 @@
"message": "显示更多运行中的实例" "message": "显示更多运行中的实例"
}, },
"app.action-bar.stop-instance": { "app.action-bar.stop-instance": {
"message": "停止实例" "message": "停止当前窗口"
}, },
"app.action-bar.update": { "app.action-bar.update": {
"message": "更新" "message": "更新"
@@ -3049,6 +3049,15 @@
"app.instance.launch-instance": { "app.instance.launch-instance": {
"message": "启动实例" "message": "启动实例"
}, },
"app.instance.launch-another": {
"message": "再开一个窗口"
},
"app.action-bar.window-started": {
"message": "{time, time, medium} 启动"
},
"app.instance.stop-all": {
"message": "停止此实例全部窗口"
},
"app.instance.linked-to": { "app.instance.linked-to": {
"message": "关联到" "message": "关联到"
}, },
@@ -5975,7 +5984,7 @@
"message": "切换主页样式" "message": "切换主页样式"
}, },
"app.onboarding.instance-actions.description": { "app.onboarding.instance-actions.description": {
"message": "从这里启动或配置实例。首次启动选择玩家后会自动记住,之后可在实例设置中切换。" "message": "从这里启动或配置实例。运行中可再开一个窗口,顶部运行列表可单独停止某个窗口。首次启动选择玩家后会自动记住,之后可在实例设置中切换。"
}, },
"app.onboarding.instance-actions.title": { "app.onboarding.instance-actions.title": {
"message": "主操作台" "message": "主操作台"
@@ -10288,6 +10297,9 @@
"minecraft-account.third-party-modal.description": { "minecraft-account.third-party-modal.description": {
"message": "使用 StarLight 皮肤站账号登录。" "message": "使用 StarLight 皮肤站账号登录。"
}, },
"minecraft-account.third-party-modal.littleskin": {
"message": "使用 LittleSkin"
},
"minecraft-account.third-party-modal.password": { "minecraft-account.third-party-modal.password": {
"message": "密码" "message": "密码"
}, },
+56 -55
View File
@@ -156,34 +156,23 @@
{{ formatMessage(commonMessages.repairButton) }} {{ formatMessage(commonMessages.repairButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large"> <ButtonStyled v-else-if="loading" color="brand" size="large">
<button :disabled="stopping" @click="stopInstance('InstancePage')"> <button disabled>
<StopCircleIcon /> <SpinnerIcon class="animate-spin" />
{{ {{ formatMessage(messages.starting, { seconds: launchElapsedSeconds }) }}
stopping
? formatMessage(messages.stopping)
: formatMessage(commonMessages.stopButton)
}}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled <ButtonStyled v-else-if="!isServerInstance" color="brand" size="large">
v-else-if="playing === false && loading === false && !isServerInstance"
color="brand"
size="large"
>
<button @click="startInstance('InstancePage')"> <button @click="startInstance('InstancePage')">
<PlayIcon /> <PlayIcon />
{{ formatMessage(commonMessages.playButton) }} {{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<div <div v-else class="joined-buttons">
v-else-if="playing === false && loading === false && isServerInstance"
class="joined-buttons"
>
<ButtonStyled color="brand" size="large"> <ButtonStyled color="brand" size="large">
<button @click="handlePlayServer()"> <button @click="handlePlayServer()">
<PlayIcon /> <PlayIcon />
{{ formatMessage(commonMessages.playButton) }} {{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="brand" size="large"> <ButtonStyled color="brand" size="large">
@@ -214,14 +203,10 @@
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
</div> </div>
<ButtonStyled <ButtonStyled v-if="playing" color="red" size="large">
v-else-if="loading === true && playing === false" <button :disabled="stopping || loading" @click="stopInstance('InstancePage')">
color="brand" <StopCircleIcon />
size="large" {{ formatMessage(stopping ? messages.stopping : messages.stopAll) }}
>
<button disabled>
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.starting, { seconds: launchElapsedSeconds }) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled circular size="large"> <ButtonStyled circular size="large">
@@ -317,7 +302,7 @@
</div> </div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick"> <ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template> <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 #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EditIcon /> {{ formatMessage(commonMessages.editButton) }} </template> <template #edit> <EditIcon /> {{ formatMessage(commonMessages.editButton) }} </template>
<template #copy_path> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template> <template #copy_path> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
@@ -437,6 +422,7 @@ import {
} from '@/helpers/instance' } from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons' import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js' import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds' import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
@@ -459,6 +445,8 @@ const props = defineProps<{
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const messages = defineMessages({ 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' }, neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
upgradedTo: { upgradedTo: {
id: 'app.instance.post-upgrade-status', id: 'app.instance.post-upgrade-status',
@@ -520,7 +508,10 @@ const postUpgradeNoticeQuery = usePostUpgradeNotice(() => instance.value?.id ??
const postUpgradeNotice = computed(() => postUpgradeNoticeQuery.data.value ?? null) const postUpgradeNotice = computed(() => postUpgradeNoticeQuery.data.value ?? null)
const symlinkWarning = useSymlinkWarningDismiss(instanceId) const symlinkWarning = useSymlinkWarningDismiss(instanceId)
const playing = ref(false) 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 launchElapsedSeconds = ref(0)
const subpagePending = ref(false) const subpagePending = ref(false)
const stopping = ref(false) const stopping = ref(false)
@@ -655,9 +646,10 @@ function fetchDeferredData(instanceId?: string) {
} }
async function updatePlayState() { async function updatePlayState() {
const runningProcesses = await get_by_instance_id(props.id).catch(handleError) const id = props.id
const runningProcesses = await get_by_instance_id(id).catch(handleError)
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0 if (props.id === id && Array.isArray(runningProcesses))
playing.value = runningProcesses.length > 0
} }
await fetchInstance() await fetchInstance()
@@ -665,6 +657,7 @@ watch(
() => props.id, () => props.id,
async () => { async () => {
if (route.path.startsWith('/instance')) { if (route.path.startsWith('/instance')) {
playing.value = false
await fetchInstance() await fetchInstance()
} }
}, },
@@ -741,19 +734,16 @@ watch(
const options = ref<InstanceType<typeof ContextMenu> | null>(null) const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => { const startInstance = async (context: string) => {
if (!instance.value) return if (!instance.value || loading.value) return
if (!offline.value && updateToPlayModal.value?.hasUpdate) { const id = props.id
const name = instance.value.name
if (!playing.value && !offline.value && updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value) updateToPlayModal.value.show(instance.value)
return return
} }
loading.value = true
launchElapsedSeconds.value = 0
launchElapsedTimer = setInterval(() => {
launchElapsedSeconds.value += 1
}, 1000)
try { try {
const result = await run(props.id) const result = await run(id)
const gcNotice = result.gc_notice const gcNotice = result.gc_notice
if (gcNotice && gcReportFellBack(gcNotice)) { if (gcNotice && gcReportFellBack(gcNotice)) {
addNotification({ addNotification({
@@ -764,39 +754,50 @@ const startInstance = async (context: string) => {
}), }),
}) })
} }
playing.value = true await updatePlayState()
} catch (err) { } catch (err) {
const handled = await handleMinecraftLaunchError(err, { const handled = await handleMinecraftLaunchError(err, {
instance_id: props.id, instance_id: id,
instance_name: instance.value.name, instance_name: name,
}) })
if (!handled) { if (!handled) {
handleSevereError(err, { instanceId: props.id }) handleSevereError(err, { instanceId: id })
} }
} finally { }
}
watch(
[loading, () => props.id],
([busy]) => {
clearInterval(launchElapsedTimer) clearInterval(launchElapsedTimer)
launchElapsedTimer = undefined launchElapsedSeconds.value = 0
loading.value = false launchElapsedTimer = busy
} ? setInterval(() => {
} launchElapsedSeconds.value += 1
}, 1000)
: undefined
},
{ immediate: true },
)
const stopInstance = async (context: string) => { const stopInstance = async (context: string) => {
stopping.value = true stopping.value = true
await kill(props.id).catch(handleError) await kill(props.id).catch(handleError)
stopping.value = false stopping.value = false
playing.value = false await updatePlayState()
if (!instance.value) return if (!instance.value) return
} }
const handlePlayServer = async () => { const handlePlayServer = async () => {
if (!instance.value?.link?.project_id) return if (!instance.value?.link?.project_id || loading.value) return
loading.value = true const id = props.id
serverLaunchingId.value = id
try { try {
await playServerProject(instance.value.link.project_id) await playServerProject(instance.value.link.project_id)
} finally { } finally {
await updatePlayState() 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 }) => { const unlistenProcesses = await process_listener((e: { event: string; instance_id: string }) => {
if (e.event === 'finished' && e.instance_id === props.id) { if (e.instance_id === props.id) {
playing.value = false void updatePlayState()
void queryClient.invalidateQueries({ queryKey: postUpgradeNoticeQueryKey(props.id) }) void queryClient.invalidateQueries({ queryKey: postUpgradeNoticeQueryKey(props.id) })
} }
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging # 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 edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher" description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red" homepage = "https://www.ghs.red"
+1 -3
View File
@@ -66,9 +66,7 @@ pub async fn hosted_default() -> Result<theseus::pack::hosted::Publication> {
} }
#[tauri::command] #[tauri::command]
pub async fn hosted_create( pub async fn hosted_create(game_dir_root: Option<String>) -> Result<String> {
game_dir_root: Option<String>,
) -> Result<String> {
Ok(theseus::pack::hosted::create(game_dir_root).await?) Ok(theseus::pack::hosted::create(game_dir_root).await?)
} }
+2 -2
View File
@@ -3,14 +3,14 @@ use futures::StreamExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::http::header::ACCEPT;
use tauri::http::HeaderValue; use tauri::http::HeaderValue;
use tauri::http::header::ACCEPT;
use tauri::{Manager, ResourceId, Runtime, Webview}; use tauri::{Manager, ResourceId, Runtime, Webview};
use tauri_plugin_http::reqwest; use tauri_plugin_http::reqwest;
use tauri_plugin_http::reqwest::ClientBuilder; use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::{Error, Update, UpdaterExt}; use tauri_plugin_updater::{Error, Update, UpdaterExt};
use theseus::{ use theseus::{
emit_loading, init_loading, launcher_user_agent, LoadingBarType, LoadingBarType, emit_loading, init_loading, launcher_user_agent,
}; };
use tokio::time::Instant; use tokio::time::Instant;
use url::Url; use url::Url;
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus" name = "theseus"
# The actual version is set by the theseus-build workflow on tagging # 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 edition.workspace = true
[dependencies] [dependencies]
+131 -3
View File
@@ -426,9 +426,7 @@ pub async fn default_publication() -> crate::Result<Publication> {
}) })
} }
pub async fn create( pub async fn create(game_dir_root: Option<String>) -> crate::Result<String> {
game_dir_root: Option<String>,
) -> crate::Result<String> {
let publication = default_publication().await?; let publication = default_publication().await?;
let runtime = &publication.manifest.runtime; let runtime = &publication.manifest.runtime;
let state = State::get().await?; let state = State::get().await?;
@@ -812,11 +810,85 @@ pub async fn prepare_launch(
"StarLight 实例必须登录并联网检查更新后才能启动;离线游玩请使用本地实例", "StarLight 实例必须登录并联网检查更新后才能启动;离线游玩请使用本地实例",
)); ));
} }
if crate::process::get_by_instance_id(instance_id)
.await?
.is_empty()
{
synchronize_locked(instance_id).await?; synchronize_locked(instance_id).await?;
} else {
check_running_pack(instance_id).await?;
}
} }
Ok(guard) Ok(guard)
} }
async fn check_running_pack(instance_id: &str) -> crate::Result<()> {
let auth = authorization().await?;
let root = crate::instance::get_full_path(instance_id).await?;
let previous: Binding = read_json(&root, BINDING)
.await?
.ok_or_else(|| invalid("请先关闭该实例的所有游戏窗口,再同步整合包"))?;
let remote = request_authorized::<Option<SyncState>>("/sync-state", &auth)
.await?
.ok_or_else(|| {
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
})?;
if !marker_matches(&previous, &remote) {
return Err(invalid(
"整合包有更新,请先关闭该实例的所有游戏窗口,更新完成后再多开",
));
}
let tagged = request_authorized::<tagged::TaggedManifest>(
&format!("/tagged-mods/{}", previous.publication.release_id),
&auth,
)
.await?;
tagged.validate()?;
if !running_pack_matches(&root, &previous, &tagged).await? {
return Err(invalid(
"整合包 Mod 有变更,请先关闭该实例的所有游戏窗口,更新完成后再多开",
));
}
ensure_session(&auth).await
}
async fn running_pack_matches(
root: &Path,
previous: &Binding,
tagged: &tagged::TaggedManifest,
) -> crate::Result<bool> {
let mut desired = previous.publication.manifest.files.clone();
desired.extend(previous.resolved_external.iter().cloned());
let duplicates = tagged::merge(
root,
&target(root, ".starlight-pack-cache")?,
&mut desired,
&mut BTreeMap::new(),
tagged,
)
.await?;
validate(&desired)?;
let inventory = |files: &[PackFile]| {
files
.iter()
.map(|file| (file.path.clone(), file.sha256.clone()))
.collect::<BTreeMap<_, _>>()
};
if !duplicates.is_empty()
|| inventory(&desired) != inventory(&previous.files)
{
return Ok(false);
}
for file in desired.iter().filter(|file| tagged.includes_content(file)) {
if hash(&target(root, &file.path)?).await?.as_deref()
!= Some(&file.sha256)
{
return Ok(false);
}
}
Ok(true)
}
async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> { async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
ensure_idle(instance_id).await?; ensure_idle(instance_id).await?;
recover(instance_id).await?; recover(instance_id).await?;
@@ -1417,6 +1489,62 @@ async fn synchronize_with_progress(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn running_pack_checks_tag_changes_without_writing_live_files() {
let root = tempfile::tempdir().unwrap();
tokio::fs::create_dir(root.path().join("mods"))
.await
.unwrap();
let path = root.path().join("mods/example.starlight.jar");
tokio::fs::write(&path, b"installed").await.unwrap();
let sha = hash(&path).await.unwrap().unwrap();
let file = serde_json::json!({"path":"mods/example.starlight.jar", "sha256":sha, "size":9,
"modIds":["example"], "force":true});
let previous: Binding = serde_json::from_value(serde_json::json!({
"publication":{"packId":"pack", "releaseId":1, "manifest":{
"schemaVersion":1, "name":"Pack", "version":"1", "format":"multimc",
"runtime":{"gameVersion":"1.21.1", "loader":"vanilla"}, "files":[]
}}, "files":[file], "syncMarker":"pack:1"
})).unwrap();
let manifest = |sha: &str| {
serde_json::from_value::<tagged::TaggedManifest>(serde_json::json!({
"files":[{"modId":"example", "modIds":["example"], "path":"mods/example.starlight.jar", "sha256":sha, "size":9}],
"replaces":[]
})).unwrap()
};
assert!(
running_pack_matches(root.path(), &previous, &manifest(&sha))
.await
.unwrap()
);
assert!(
!running_pack_matches(
root.path(),
&previous,
&manifest(&"a".repeat(64))
)
.await
.unwrap()
);
let empty = serde_json::from_value(
serde_json::json!({"files":[], "replaces":[]}),
)
.unwrap();
assert!(
!running_pack_matches(root.path(), &previous, &empty)
.await
.unwrap()
);
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"installed");
assert!(!root.path().join(JOURNAL).exists());
tokio::fs::write(&path, b"modified").await.unwrap();
assert!(
!running_pack_matches(root.path(), &previous, &manifest(&sha))
.await
.unwrap()
);
}
#[test] #[test]
fn sync_marker_requires_an_installed_publication_and_changes_on_default_or_release() fn sync_marker_requires_an_installed_publication_and_changes_on_default_or_release()
{ {
@@ -23,6 +23,10 @@ pub(super) struct TaggedManifest {
} }
impl TaggedManifest { impl TaggedManifest {
pub fn includes_content(&self, file: &PackFile) -> bool {
self.files.iter().any(|tagged| tagged.sha256 == file.sha256)
}
pub fn contains(&self, path: &str) -> bool { pub fn contains(&self, path: &str) -> bool {
self.files.iter().any(|file| file.path == path) self.files.iter().any(|file| file.path == path)
} }
@@ -51,8 +51,7 @@ pub async fn import_generic(
let info = detect_instance_info(&layout.json_source, overrides).await?; let info = detect_instance_info(&layout.json_source, overrides).await?;
register_instance(instance_id, &layout.name, &info).await?; register_instance(instance_id, &layout.name, &info).await?;
copy_instance_files(instance_id, &layout, reporter, details, symlink) copy_instance_files(instance_id, &layout, reporter, details, symlink).await
.await
} }
/// The resolved source layout for a generic import. /// The resolved source layout for a generic import.
@@ -589,8 +589,8 @@ fn try_extract_version_from_needle(
) -> Option<String> { ) -> Option<String> {
let pos = content.find(needle)?; let pos = content.find(needle)?;
let after = &content[pos + needle.len()..]; let after = &content[pos + needle.len()..];
let end = after let end =
.find(&['"', ',', '\n', '}', ']', '[', ':', ' '] as &[char])?; after.find(&['"', ',', '\n', '}', ']', '[', ':', ' '] as &[char])?;
let ver = &after[..end]; let ver = &after[..end];
if let Some(ch) = split_at if let Some(ch) = split_at
&& let Some(pos) = ver.rfind(ch) && let Some(pos) = ver.rfind(ch)
@@ -609,7 +609,11 @@ fn sanitize_loader_version(raw: &str) -> String {
// Cut at the first character that cannot appear in a loader version id. // Cut at the first character that cannot appear in a loader version id.
let end = trimmed let end = trimmed
.find(|ch: char| { .find(|ch: char| {
!(ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' || ch == '+') !(ch.is_ascii_alphanumeric()
|| ch == '.'
|| ch == '-'
|| ch == '_'
|| ch == '+')
}) })
.unwrap_or(trimmed.len()); .unwrap_or(trimmed.len());
trimmed[..end].trim().to_string() trimmed[..end].trim().to_string()
+8 -3
View File
@@ -1234,10 +1234,13 @@ fn is_other_version_entry(rel: &Path, keep: &Path) -> bool {
let mut keep_components = keep.components(); let mut keep_components = keep.components();
// Both must start with the literal `versions` component. // Both must start with the literal `versions` component.
if rel_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) { if rel_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref())
{
return false; return false;
} }
if keep_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) { if keep_components.next().map(|c| c.as_os_str())
!= Some("versions".as_ref())
{
return false; return false;
} }
@@ -1253,7 +1256,9 @@ fn is_other_version_entry(rel: &Path, keep: &Path) -> bool {
let rel_is_inside_version_dir = rel_components.next().is_some(); let rel_is_inside_version_dir = rel_components.next().is_some();
match (rel_version, keep_version) { match (rel_version, keep_version) {
(Some(rel_v), Some(keep_v)) if rel_is_inside_version_dir => rel_v != keep_v, (Some(rel_v), Some(keep_v)) if rel_is_inside_version_dir => {
rel_v != keep_v
}
// A file directly under `versions/`, shared metadata: keep it. // A file directly under `versions/`, shared metadata: keep it.
_ => false, _ => false,
} }
@@ -338,22 +338,6 @@ impl DirectLinkedLaunch {
} }
} }
pub(crate) fn natives_cache_key(&self) -> String {
let identity = format!(
"{}\0{}\0{}",
self.dot_minecraft.display(),
self.version_id,
self.version_json
.as_deref()
.unwrap_or_else(|| self.dot_minecraft.as_path())
.display()
);
format!(
"linked-{}",
sha1_smol::Sha1::from(identity.as_bytes()).hexdigest()
)
}
pub(crate) fn library_path( pub(crate) fn library_path(
&self, &self,
library: &LinkedLibrary, library: &LinkedLibrary,
+17 -41
View File
@@ -2045,17 +2045,6 @@ pub async fn launch_minecraft(
let env_args = Vec::from(env_args); let env_args = Vec::from(env_args);
// Check if instance has a running process, and reject running the command if it does
// Done late so a quick double call doesn't launch two instances
let existing_processes = process::get_by_instance_id(&instance.id).await?;
if let Some(process) = existing_processes.first() {
return Err(crate::ErrorKind::LauncherError(format!(
"Instance {} is already running as process {}",
instance.id, process.uuid
))
.as_error());
}
// Ensure phase (HMCL/PCL parity): before anything reads from the linked // Ensure phase (HMCL/PCL parity): before anything reads from the linked
// installation, complete its missing libraries, assets, and logging // installation, complete its missing libraries, assets, and logging
// config in the standard shared locations. Version JSONs, launcher // config in the standard shared locations. Version JSONs, launcher
@@ -2076,26 +2065,15 @@ pub async fn launch_minecraft(
.await?; .await?;
} }
let natives_dir = if let Some(direct) = &direct_launch { let (main_class_keep_alive, main_class_path) =
state get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
.directories let natives_dir = main_class_keep_alive.path().join("natives");
.version_natives_dir(&direct.natives_cache_key())
} else {
state.directories.version_natives_dir(&version_jar)
};
// Linked native archives can change outside Axolotl, so rebuild only the
// Axolotl-owned linked cache on every launch. Never mutate linked folders.
if direct_launch.is_some() && natives_dir.exists() {
io::remove_dir_all(&natives_dir).await?;
}
if !natives_dir.exists() {
io::create_dir_all(&natives_dir).await?; io::create_dir_all(&natives_dir).await?;
}
if let (Some(direct), Some(libraries)) = if let (Some(direct), Some(libraries)) =
(direct_launch.clone(), linked_libraries.clone()) (direct_launch.clone(), linked_libraries.clone())
{ {
// Linked instances rebuild the Axolotl-owned natives cache on every // Each process owns its native DLLs until it exits. Rebuilding a
// launch; the managed restore path below must never touch them. // shared directory fails on Windows while another JVM holds a DLL.
let target = natives_dir.clone(); let target = natives_dir.clone();
let java_arch = java_version.architecture.clone(); let java_arch = java_version.architecture.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
@@ -2109,18 +2087,7 @@ pub async fn launch_minecraft(
}) })
.await??; .await??;
} else if direct_launch.is_none() { } else if direct_launch.is_none() {
if offline_mode { if !offline_mode {
natives::prepare_native_libraries(
&state.directories.natives_dir(),
&state.directories.libraries_dir(),
&state.directories.caches_dir(),
version_info.libraries.as_slice(),
&version_jar,
&java_version.architecture,
minecraft_updated,
)
.await?;
} else {
download::download_libraries( download::download_libraries(
&state, &state,
None, None,
@@ -2135,6 +2102,16 @@ pub async fn launch_minecraft(
) )
.await?; .await?;
} }
natives::prepare_native_libraries(
main_class_keep_alive.path(),
&state.directories.libraries_dir(),
&state.directories.caches_dir(),
version_info.libraries.as_slice(),
"natives",
&java_version.architecture,
minecraft_updated,
)
.await?;
} }
tracing::debug!( tracing::debug!(
@@ -2172,8 +2149,6 @@ pub async fn launch_minecraft(
address.resolve().await?; address.resolve().await?;
} }
let (main_class_keep_alive, main_class_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
let yggdrasil_agent = if credentials.is_yggdrasil() { let yggdrasil_agent = if credentials.is_yggdrasil() {
let account = credentials.yggdrasil.as_ref().ok_or_else(|| { let account = credentials.yggdrasil.as_ref().ok_or_else(|| {
crate::ErrorKind::LauncherError( crate::ErrorKind::LauncherError(
@@ -2323,6 +2298,7 @@ pub async fn launch_minecraft(
let options_existed = options_path.exists(); let options_existed = options_path.exists();
if direct_launch.is_none() if direct_launch.is_none()
&& process::get_by_instance_id(&instance.id).await?.is_empty()
&& (!mc_set_options.is_empty() && (!mc_set_options.is_empty()
|| offline_skin_pack.enabled_pack_id.is_some() || offline_skin_pack.enabled_pack_id.is_some()
|| options_existed || options_existed
+41
View File
@@ -486,6 +486,47 @@ mod tests {
} }
} }
#[tokio::test]
async fn concurrent_launch_native_directories_do_not_replace_each_other() {
let archives = tempfile::tempdir().unwrap();
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let source = archives.path().join("natives.jar");
write_archive(&source, &[("lwjgl.dll", b"first runtime")]);
materialize_native_directory(
&[archive(source.clone())],
first.path(),
"natives",
)
.await
.unwrap();
let first_dll = first.path().join("natives/lwjgl.dll");
#[cfg(windows)]
let _running_dll = {
use std::os::windows::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&first_dll)
.unwrap()
};
write_archive(&source, &[("lwjgl.dll", b"second runtime")]);
materialize_native_directory(
&[archive(source)],
second.path(),
"natives",
)
.await
.unwrap();
assert_eq!(std::fs::read(&first_dll).unwrap(), b"first runtime");
assert_eq!(
std::fs::read(second.path().join("natives/lwjgl.dll")).unwrap(),
b"second runtime"
);
drop(second);
assert!(first_dll.exists());
}
#[tokio::test] #[tokio::test]
async fn repairs_missing_truncated_same_size_and_directory_entries() { async fn repairs_missing_truncated_same_size_and_directory_entries() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
+81 -10
View File
@@ -310,13 +310,20 @@ impl ProcessManager {
let log_path = logs_folder.join(LAUNCHER_LOG_PATH); let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
let already_running = self
.get_all()
.iter()
.any(|process| process.instance_id == instance_id);
if !already_running {
clear_log_buffer(instance_id); clear_log_buffer(instance_id);
}
{ {
let mut log_file = OpenOptions::new() let mut log_file = OpenOptions::new()
.write(true) .write(true)
.create(true) .create(true)
.truncate(true) .truncate(!already_running)
.append(already_running)
.open(&log_path) .open(&log_path)
.map_err(|e| IOError::with_path(e, &log_path))?; .map_err(|e| IOError::with_path(e, &log_path))?;
@@ -490,8 +497,8 @@ impl ProcessManager {
} }
pub async fn wait_for(&self, id: Uuid) -> crate::Result<()> { pub async fn wait_for(&self, id: Uuid) -> crate::Result<()> {
if let Some(mut process) = self.processes.get_mut(&id) { while matches!(self.try_wait(id)?, Some(None)) {
process.child.wait().await?; tokio::time::sleep(std::time::Duration::from_millis(50)).await;
} }
Ok(()) Ok(())
} }
@@ -499,7 +506,7 @@ impl ProcessManager {
pub async fn kill(&self, id: Uuid) -> crate::Result<()> { pub async fn kill(&self, id: Uuid) -> crate::Result<()> {
if let Some(mut process) = self.processes.get_mut(&id) { if let Some(mut process) = self.processes.get_mut(&id) {
process.manually_killed = true; process.manually_killed = true;
if let Err(error) = process.child.kill().await { if let Err(error) = process.child.start_kill() {
process.manually_killed = false; process.manually_killed = false;
return Err(error.into()); return Err(error.into());
} }
@@ -631,7 +638,12 @@ impl Process {
let mut chunk = [0u8; 8192]; let mut chunk = [0u8; 8192];
loop { loop {
let read = match tokio::io::AsyncReadExt::read(&mut buf_reader, &mut chunk).await { let read = match tokio::io::AsyncReadExt::read(
&mut buf_reader,
&mut chunk,
)
.await
{
Ok(0) => break, Ok(0) => break,
Ok(n) => n, Ok(n) => n,
Err(e) => { Err(e) => {
@@ -772,8 +784,8 @@ impl Process {
} }
b"log4j:Throwable" => { b"log4j:Throwable" => {
in_throwable = false; in_throwable = false;
current_event.throwable = current_event.throwable = if current_content.is_empty()
if current_content.is_empty() { {
None None
} else { } else {
Some(current_content.clone()) Some(current_content.clone())
@@ -792,8 +804,7 @@ impl Process {
} }
if let Some(ref throwable) = current_event.throwable if let Some(ref throwable) = current_event.throwable
&& let Err(e) = Self::append_to_log_file( && let Err(e) = Self::append_to_log_file(
log_path, log_path, throwable,
throwable,
) )
{ {
tracing::error!( tracing::error!(
@@ -1176,7 +1187,15 @@ impl Process {
} }
} }
} }
crate::api::multiplayer::minecraft_process_finished(&instance_id).await; if !state
.process_manager
.get_all()
.iter()
.any(|process| process.instance_id == instance_id)
{
crate::api::multiplayer::minecraft_process_finished(&instance_id)
.await;
}
// Now fully complete- update playtime one last time // Now fully complete- update playtime one last time
update_playtime(&mut last_updated_playtime, &instance_id, true).await; update_playtime(&mut last_updated_playtime, &instance_id, true).await;
@@ -1293,6 +1312,58 @@ fn take_next_log4j_frame(buffer: &mut String) -> Option<String> {
mod post_upgrade_tests { mod post_upgrade_tests {
use super::*; use super::*;
#[cfg(windows)]
#[tokio::test]
async fn waiting_for_one_process_leaves_registry_available() {
let manager = ProcessManager::new();
let mut child = Command::new("powershell.exe");
child
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Start-Sleep -Seconds 30",
])
.creation_flags(0x08000000)
.kill_on_drop(true);
let child = child.spawn().unwrap();
let uuid = Uuid::new_v4();
let rpc = crate::util::rpc::RpcServerBuilder::new()
.launch()
.await
.unwrap();
manager.processes.insert(
uuid,
Process {
metadata: ProcessMetadata {
uuid,
pid: child.id().unwrap(),
maximize_window: false,
instance_id: "first".into(),
instance_path: "first".into(),
instance_name: "First".into(),
start_time: Utc::now(),
},
child,
manually_killed: false,
output_tasks: Vec::new(),
_main_class_keep_alive: tempfile::tempdir().unwrap(),
rpc_server: rpc,
},
);
let waiting = manager.wait_for(uuid);
tokio::pin!(waiting);
assert!(futures::poll!(&mut waiting).is_pending());
assert!(!manager.processes.try_get(&uuid).is_locked());
assert_eq!(manager.get_all().len(), 1);
manager.kill(uuid).await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), waiting)
.await
.unwrap()
.unwrap();
assert!(manager.was_manually_killed(uuid));
}
#[test] #[test]
fn live_log_lines_are_truncated_on_character_boundaries() { fn live_log_lines_are_truncated_on_character_boundaries() {
let line = format!("prefix{}suffix", "你".repeat(40_000)); let line = format!("prefix{}suffix", "你".repeat(40_000));
+4 -4
View File
@@ -2,11 +2,11 @@ import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
const migrationsDirectory = 'packages/app-lib/migrations' const migrationsDirectory = 'packages/app-lib/migrations'
const repository = process.env.GITHUB_REPOSITORY ?? 'Mystic-Stars/Axolotl' const repository = process.env.GITHUB_REPOSITORY ?? 'ApTx2012/Starlight_Lancher'
// v1.7.5 is the canonical snapshot immediately before the v1.7.6 incident. // Starlight's first release anchors the immutable migration history in this fork.
const canonicalBootstrap = { const canonicalBootstrap = {
ref: '7ddbfb8e57db4b0044a04cf28f25fb29e08c3279', ref: '71a8e65085c60eeb4154dd3fc39c0d84ed6fd18c',
tag: 'v1.7.5', tag: 'v1.0.0-beta1',
} }
// Published divergences remain evidence of an incident and never replace the canonical bytes. // Published divergences remain evidence of an incident and never replace the canonical bytes.
const knownPublishedDivergences = new Set([ const knownPublishedDivergences = new Set([
@@ -62,7 +62,11 @@ try {
const verify = spawnSync( const verify = spawnSync(
process.execPath, process.execPath,
['scripts/axolotl/verify-update-manifest.mjs', outputPath, tag], ['scripts/axolotl/verify-update-manifest.mjs', outputPath, tag],
{ cwd: root, encoding: 'utf8' }, {
cwd: root,
encoding: 'utf8',
env: { ...process.env, GITHUB_REPOSITORY: 'Mystic-Stars/Axolotl' },
},
) )
assert.equal(verify.status, 0, verify.stderr) assert.equal(verify.status, 0, verify.stderr)
} finally { } finally {
+1
View File
@@ -17,6 +17,7 @@ const allowedUntranslatedMessages = new Set([
'.minecraft', '.minecraft',
'Striding Hero', 'Striding Hero',
'Axolotl Launcher', 'Axolotl Launcher',
'Starlight Launcher',
'Explore high-quality Minecraft content on Modrinth.', 'Explore high-quality Minecraft content on Modrinth.',
'example.modrinth.gg', 'example.modrinth.gg',
'{title} - {count}', '{title} - {count}',
+8 -2
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs'
const [manifestPath, tag, source = 'github'] = process.argv.slice(2) const [manifestPath, tag, source = 'github'] = process.argv.slice(2)
const expectedVersion = tag?.replace(/^v/, '') const expectedVersion = tag?.replace(/^v/, '')
const githubRepository = process.env.GITHUB_REPOSITORY || 'ApTx2012/Starlight_Lancher'
const cnbRepository = process.env.CNB_REPO_SLUG || 'axlmc/Axolotl'
if (!manifestPath || !expectedVersion || !['github', 'cnb'].includes(source)) { if (!manifestPath || !expectedVersion || !['github', 'cnb'].includes(source)) {
throw new Error('Usage: node verify-update-manifest.mjs <latest.json> <version-tag> [github|cnb]') throw new Error('Usage: node verify-update-manifest.mjs <latest.json> <version-tag> [github|cnb]')
@@ -34,9 +36,13 @@ for (const platform of requiredPlatforms) {
url.protocol === 'https:' && url.protocol === 'https:' &&
(source === 'github' (source === 'github'
? url.hostname === 'github.com' && ? url.hostname === 'github.com' &&
pathname.includes('/mystic-stars/axolotl/releases/download/') pathname.startsWith(
`/${githubRepository.toLowerCase()}/releases/download/${tag.toLowerCase()}/`,
)
: url.hostname === 'cnb.cool' && : url.hostname === 'cnb.cool' &&
pathname.includes(`/axlmc/axolotl/-/releases/download/${tag.toLowerCase()}/`)) pathname.startsWith(
`/${cnbRepository.toLowerCase()}/-/releases/download/${tag.toLowerCase()}/`,
))
if (!isExpectedUrl) { if (!isExpectedUrl) {
throw new Error(`Unexpected ${source} update URL for ${platform}: ${update.url}`) throw new Error(`Unexpected ${source} update URL for ${platform}: ${update.url}`)
} }
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'node:test'
test('update manifests must point to the configured repository and release tag', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'starlight-manifest-'))
const manifestPath = path.join(directory, 'latest.json')
const tag = 'v1.0.0-beta12'
const repository = 'ApTx2012/Starlight_Lancher'
const platforms = [
'darwin-aarch64',
'darwin-x86_64',
'linux-aarch64',
'linux-x86_64',
'windows-x86_64',
]
const verify = (url) => {
fs.writeFileSync(
manifestPath,
JSON.stringify({
version: tag.slice(1),
platforms: Object.fromEntries(
platforms.map((platform) => [
platform,
{
url,
signature: 'x'.repeat(64),
},
]),
),
apt: Object.fromEntries(
['linux-aarch64', 'linux-x86_64'].map((platform) => [
platform,
{
url,
sha256: 'a'.repeat(64),
size: 123,
},
]),
),
}),
)
return spawnSync(
process.execPath,
['scripts/axolotl/verify-update-manifest.mjs', manifestPath, tag],
{
env: { ...process.env, GITHUB_REPOSITORY: repository },
encoding: 'utf8',
},
)
}
try {
const success = verify(
`https://github.com/${repository}/releases/download/${tag}/installer.zip`,
)
assert.equal(success.status, 0, success.stderr)
for (const url of [
`https://github.com/Mystic-Stars/Axolotl/releases/download/${tag}/installer.zip`,
`https://github.com/${repository}/releases/download/v1.0.0-beta1/installer.zip`,
`https://example.com/${repository}/releases/download/${tag}/installer.zip`,
]) {
const result = verify(url)
assert.notEqual(result.status, 0)
assert.match(result.stderr, /Unexpected github update URL/)
}
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})