Files
Starlight_Lancher/apps/app-frontend/src/components/ui/MinecraftCrashModal.vue
T
Disy d2a319a4d5
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
fix: 启用启动器更新并修复初始化竞态
2026-09-26 13:06:51 +08:00

665 lines
21 KiB
Vue

<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
shareLogs,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import CrashAIExplanationModal from '@/components/ui/CrashAIExplanationModal.vue'
import CrashModChangesModal from '@/components/ui/CrashModChangesModal.vue'
import {
clearCrashAnalysis,
type CrashAnalysisResult,
refreshCrashAnalysis,
} from '@/composables/useCrashAnalysis'
import type { MinecraftLaunchErrorPayload } from '@/composables/useMinecraftLaunchError'
import { getAIState } from '@/helpers/ai'
import { process_listener } from '@/helpers/events.js'
import { get as getInstance } from '@/helpers/instance'
import { get_crash_analysis_ai_settings } from '@/helpers/logs.js'
import { shouldShowMinecraftCrash } from '@/helpers/process.js'
interface CrashModalPayload extends MinecraftLaunchErrorPayload {
title?: string
summary?: string
body?: string
hint?: string
}
interface ProcessEvent {
instance_id: string
uuid: string
event: 'launched' | 'finished'
crashed?: boolean
}
interface CrashWarningPayload extends MinecraftLaunchErrorPayload {
kind: 'minecraft_crash'
}
type Unlisten = () => void
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const aiModal = ref<InstanceType<typeof CrashAIExplanationModal>>()
const modChangesModal = ref<InstanceType<typeof CrashModChangesModal>>()
const payload = ref<Partial<CrashModalPayload>>({})
const sharing = ref(false)
let lastAnalysis: CrashAnalysisResult | null = null
const modChangesAvailable = ref(false)
const activeRuns = new Map<string, string>()
const lastShownAt = new Map<string, number>()
let unlistenProcess: Unlisten | undefined
let mounted = false
let analysisVersion = 0
const aiAvailable = ref(false)
const messages = defineMessages({
title: {
id: 'app.minecraft-crash.title',
defaultMessage: '{instanceName} crashed',
},
body: {
id: 'app.minecraft-crash.body',
defaultMessage:
'Do not send a screenshot of this window when asking for help. Export the error report instead so the crash report, game logs, debug log, and JVM details can be checked together.',
},
summary: {
id: 'app.minecraft-crash.summary',
defaultMessage: 'Minecraft stopped unexpectedly.',
},
supportHint: {
id: 'app.minecraft-crash.support-hint',
defaultMessage:
'When asking for help, send the exported ZIP. Do not send only a screenshot of this window because it does not contain the diagnostic evidence.',
},
previewInstance: {
id: 'app.minecraft-crash.preview-instance',
defaultMessage: 'Minecraft test instance',
},
launchFailedTitle: {
id: 'app.minecraft-crash.launch-failed-title',
defaultMessage: '{instanceName} could not start',
},
launchFailedSummary: {
id: 'app.minecraft-crash.launch-failed-summary',
defaultMessage: 'Minecraft failed during launch preparation.',
},
exitedBeforeInitialization: {
id: 'app.minecraft-crash.exited-before-initialization',
defaultMessage:
'The Java process exited before it could connect to the launcher. The selected Java version is probably incompatible with this Minecraft or Mod loader version. Select the Java version required by the instance, then try again.',
},
initializationTimedOut: {
id: 'app.minecraft-crash.initialization-timed-out',
defaultMessage:
'The Java process started but did not connect to the launcher within 15 seconds. Check the selected Java version and any wrapper command, then try again.',
},
preparationTimedOut: {
id: 'app.minecraft-crash.preparation-timed-out',
defaultMessage:
'Launch preparation did not finish within 60 seconds and was cancelled. Check the Java path, launch hooks, wrapper command, and network connection, then try again.',
},
launchFailureHint: {
id: 'app.minecraft-crash.launch-failure-hint',
defaultMessage:
'Open the Minecraft logs to view the captured Java output. When asking for help, export and send the complete Minecraft diagnostic package.',
},
analyzing: {
id: 'app.minecraft-crash.analyzing',
defaultMessage: 'Analyzing the logs from this launch...',
},
evidence: {
id: 'app.minecraft-crash.evidence',
defaultMessage: 'Reference evidence: {evidence}',
},
viewModChanges: {
id: 'app.minecraft-crash.view-mod-changes',
defaultMessage: 'View Mod changes',
},
modChangesTitle: {
id: 'app.minecraft-crash.mod-changes-title',
defaultMessage: 'Possible issue: Mod files changed since the last successful launch',
},
modChangesAction: {
id: 'app.minecraft-crash.mod-changes-action',
defaultMessage:
'Review the changed Mod files and restore the previous setup manually if the crash started after those changes.',
},
jvmArgumentsTitle: {
id: 'app.minecraft-crash.diagnosis.jvm-arguments.title',
defaultMessage: 'Possible issue: the JVM arguments are invalid',
},
jvmArgumentsAction: {
id: 'app.minecraft-crash.diagnosis.jvm-arguments.action',
defaultMessage:
'You can try removing the JVM argument shown below from the instance settings, then launch again.',
},
javaTooNewTitle: {
id: 'app.minecraft-crash.diagnosis.java-too-new.title',
defaultMessage: 'Possible issue: the selected Java version is too new',
},
javaTooNewAction: {
id: 'app.minecraft-crash.diagnosis.java-too-new.action',
defaultMessage:
'You can try selecting the Java major version required by this Minecraft and Mod loader version, then launch again.',
},
javaIncompatibleTitle: {
id: 'app.minecraft-crash.diagnosis.java-incompatible.title',
defaultMessage: 'Possible issue: the Java version is incompatible',
},
javaIncompatibleAction: {
id: 'app.minecraft-crash.diagnosis.java-incompatible.action',
defaultMessage:
'You can try selecting the Java version requested in the error below, or using a compatible build of the affected Mod.',
},
java32BitTitle: {
id: 'app.minecraft-crash.diagnosis.java-32bit.title',
defaultMessage: 'Possible issue: 32-bit Java cannot allocate enough memory',
},
java32BitAction: {
id: 'app.minecraft-crash.diagnosis.java-32bit.action',
defaultMessage:
'You can try installing and selecting a 64-bit Java runtime, then launch again.',
},
java11RequiredTitle: {
id: 'app.minecraft-crash.diagnosis.java-11-required.title',
defaultMessage: 'Possible issue: a Mod requires Java 11',
},
java11RequiredAction: {
id: 'app.minecraft-crash.diagnosis.java-11-required.action',
defaultMessage:
'You can try selecting Java 11, or installing a build of the affected Mod that supports the current Java version.',
},
openJ9Title: {
id: 'app.minecraft-crash.diagnosis.openj9.title',
defaultMessage: 'Possible issue: OpenJ9 is not compatible with this instance',
},
openJ9Action: {
id: 'app.minecraft-crash.diagnosis.openj9.action',
defaultMessage:
'You can try selecting a HotSpot-based Java runtime, such as the bundled Minecraft runtime or Eclipse Temurin.',
},
jdkRuntimeTitle: {
id: 'app.minecraft-crash.diagnosis.jdk-runtime.title',
defaultMessage: 'Possible issue: the selected JDK is not compatible',
},
jdkRuntimeAction: {
id: 'app.minecraft-crash.diagnosis.jdk-runtime.action',
defaultMessage:
'You can try selecting a standard HotSpot Java runtime for this Minecraft version.',
},
forgeJavaTitle: {
id: 'app.minecraft-crash.diagnosis.forge-java.title',
defaultMessage: 'Possible issue: Forge is not compatible with the selected Java version',
},
forgeJavaAction: {
id: 'app.minecraft-crash.diagnosis.forge-java.action',
defaultMessage:
'You can try using the Java version expected by this Forge release, or updating Forge.',
},
outOfMemoryTitle: {
id: 'app.minecraft-crash.diagnosis.out-of-memory.title',
defaultMessage: 'Possible issue: Minecraft ran out of memory',
},
outOfMemoryAction: {
id: 'app.minecraft-crash.diagnosis.out-of-memory.action',
defaultMessage:
'You can try increasing the instance memory allocation, or removing memory-heavy Mods and resource packs.',
},
diskSpaceTitle: {
id: 'app.minecraft-crash.diagnosis.disk-space.title',
defaultMessage: 'Possible issue: the disk ran out of free space',
},
diskSpaceAction: {
id: 'app.minecraft-crash.diagnosis.disk-space.action',
defaultMessage:
'Free space on the drive containing this instance, then launch Minecraft again.',
},
fileInUseTitle: {
id: 'app.minecraft-crash.diagnosis.file-in-use.title',
defaultMessage: 'Possible issue: another process is using a required file',
},
fileInUseAction: {
id: 'app.minecraft-crash.diagnosis.file-in-use.action',
defaultMessage:
'Close the program named in the log, including other launchers, backup tools, or antivirus scans, then launch again.',
},
knownFailureTitle: {
id: 'app.minecraft-crash.diagnosis.known-failure.title',
defaultMessage: 'Possible issue: a specific launch problem was detected',
},
knownFailureAction: {
id: 'app.minecraft-crash.diagnosis.known-failure.action',
defaultMessage:
'This is an automatic guess, not a guaranteed diagnosis. Open the log analysis for the full context before applying the suggested fix.',
},
shareDiagnostic: {
id: 'app.minecraft-crash.share-diagnostic',
defaultMessage: 'Share diagnostic',
},
sharingDiagnostic: {
id: 'app.minecraft-crash.sharing-diagnostic',
defaultMessage: 'Sharing diagnostic...',
},
shareFailed: {
id: 'app.minecraft-crash.share-failed',
defaultMessage: 'Failed to share the diagnostic',
},
shareTruncated: {
id: 'app.minecraft-crash.share-truncated',
defaultMessage: 'The diagnostic log is too large, so only the last 9 MB was uploaded.',
},
shareCopied: {
id: 'app.minecraft-crash.share-copied',
defaultMessage: 'Diagnostic link copied to your clipboard',
},
shareReady: {
id: 'app.minecraft-crash.share-ready',
defaultMessage: 'Diagnostic link is ready to share',
},
copyLink: {
id: 'app.minecraft-crash.copy-link',
defaultMessage: 'Copy link',
},
aiAnalyze: {
id: 'app.crash-analysis.ai.action',
defaultMessage: 'Use AI to explain',
},
noLogContent: {
id: 'app.minecraft-crash.no-log-content',
defaultMessage:
'No log content was found to share or analyze. Make sure the instance has logs generated in the last few minutes.',
},
})
const diagnosisMessages = {
jvm_arguments: [messages.jvmArgumentsTitle, messages.jvmArgumentsAction],
java_too_new: [messages.javaTooNewTitle, messages.javaTooNewAction],
java_incompatible: [messages.javaIncompatibleTitle, messages.javaIncompatibleAction],
java_32bit: [messages.java32BitTitle, messages.java32BitAction],
java_11_required: [messages.java11RequiredTitle, messages.java11RequiredAction],
openj9: [messages.openJ9Title, messages.openJ9Action],
jdk_runtime: [messages.jdkRuntimeTitle, messages.jdkRuntimeAction],
forge_java_incompatible: [messages.forgeJavaTitle, messages.forgeJavaAction],
out_of_memory: [messages.outOfMemoryTitle, messages.outOfMemoryAction],
disk_space: [messages.diskSpaceTitle, messages.diskSpaceAction],
file_in_use: [messages.fileInUseTitle, messages.fileInUseAction],
} as const
const title = computed(
() =>
payload.value.title ||
formatMessage(messages.title, {
instanceName: payload.value.instance_name || 'Minecraft',
}),
)
const summary = computed(() => payload.value.summary || formatMessage(messages.summary))
const body = computed(() => payload.value.body || formatMessage(messages.body))
const hint = computed(() => payload.value.hint || formatMessage(messages.supportHint))
const showSupportHint = computed(() => hint.value !== formatMessage(messages.supportHint))
function applyAnalysis(
modalPayload: CrashModalPayload,
analysis: CrashAnalysisResult | null,
): CrashModalPayload {
const finding = analysis?.findings[0]
const modChanges = analysis?.mod_changes ?? []
if (!finding && modChanges.length === 0) return modalPayload
const diagnosis = finding
? diagnosisMessages[finding.id as keyof typeof diagnosisMessages]
: undefined
const [titleMessage, actionMessage] = diagnosis ?? [
messages.knownFailureTitle,
messages.knownFailureAction,
]
const resolvedTitleMessage = finding ? titleMessage : messages.modChangesTitle
const resolvedActionMessage = finding ? actionMessage : messages.modChangesAction
const evidence = finding?.evidence[0]
return {
...modalPayload,
summary: formatMessage(resolvedTitleMessage),
body: formatMessage(resolvedActionMessage),
hint: evidence
? formatMessage(messages.evidence, {
evidence: `${evidence.filename}:${evidence.line} - ${evidence.text}`,
})
: modalPayload.hint,
/*
...(false
? {
hint: `${formatMessage(messages.modChanges, {
changes: modChanges.map((change) => `${change.kind}: ${change.filename}`).join('; '),
})}${
evidence
? ` ${formatMessage(messages.evidence, {
evidence: `${evidence.filename}:${evidence.line} - ${evidence.text}`,
})}`
: ''
}`,
}
: {}),
*/
}
}
function show(modalPayload: CrashModalPayload, isPreview = false): boolean {
if (!isPreview) {
const now = Date.now()
const lastShown = lastShownAt.get(modalPayload.instance_id) ?? 0
if (now - lastShown < 5000) return false
lastShownAt.set(modalPayload.instance_id, now)
}
analysisVersion += 1
payload.value = modalPayload
modal.value?.show()
return true
}
function openModChanges(): void {
if (lastAnalysis?.mod_changes.length) modChangesModal.value?.show(lastAnalysis)
}
function launchErrorText(error: unknown): string {
if (typeof error === 'string') return error
if (error && typeof error === 'object') {
const record = error as Record<string, unknown>
const values = [record.message, record.error, record.cause]
.filter((value): value is string => typeof value === 'string')
.join('\n')
if (values) return values
try {
return JSON.stringify(error)
} catch {
return ''
}
}
return String(error)
}
function launchFailureBody(error: unknown): string | null {
const errorText = launchErrorText(error)
if (errorText.includes('Minecraft exited before launcher initialization completed')) {
return formatMessage(messages.exitedBeforeInitialization)
}
if (errorText.includes('Minecraft launcher initialization did not respond')) {
return formatMessage(messages.initializationTimedOut)
}
if (errorText.includes('Minecraft launch preparation timed out')) {
return formatMessage(messages.preparationTimedOut)
}
return null
}
function isLaunchFailure(error: unknown): boolean {
return launchFailureBody(error) !== null
}
async function analyzeAndUpdate(
modalPayload: CrashModalPayload,
fallbackHint?: string,
): Promise<CrashAnalysisResult | null> {
const version = analysisVersion
const analysis = await refreshCrashAnalysis(modalPayload.instance_id).catch((error) => {
console.error('Failed to analyze Minecraft crash', error)
return null
})
lastAnalysis = analysis
modChangesAvailable.value = !!analysis?.mod_changes.length
if (mounted && version === analysisVersion) {
payload.value = applyAnalysis(modalPayload, analysis)
if (!analysis?.findings.length && fallbackHint) payload.value.hint = fallbackHint
}
return analysis
}
async function handleLaunchError(
error: unknown,
launchPayload: MinecraftLaunchErrorPayload,
): Promise<boolean> {
const failureBody = launchFailureBody(error)
if (!failureBody) return false
const instanceName = launchPayload.instance_name || 'Minecraft'
const modalPayload: CrashModalPayload = {
...launchPayload,
title: formatMessage(messages.launchFailedTitle, { instanceName }),
summary: formatMessage(messages.launchFailedSummary),
body: failureBody,
hint: formatMessage(messages.analyzing),
}
if (!show(modalPayload)) return true
await analyzeAndUpdate(modalPayload, formatMessage(messages.launchFailureHint))
return true
}
async function handleWarning(warning: CrashWarningPayload): Promise<void> {
const modalPayload = { ...warning, hint: formatMessage(messages.analyzing) }
if (!show(modalPayload)) return
await analyzeAndUpdate(modalPayload)
}
function showPreview(): void {
show(
{
instance_id: 'preview',
instance_name: formatMessage(messages.previewInstance),
},
true,
)
}
const shareUrl = ref('')
function notifyNoLogContent(): void {
addNotification({
title: formatMessage(messages.noLogContent),
type: 'warning',
})
}
async function shareDiagnostic(): Promise<void> {
if (sharing.value) return
if (!lastAnalysis?.combined_log) {
notifyNoLogContent()
return
}
sharing.value = true
shareUrl.value = ''
try {
const result = await shareLogs(client, lastAnalysis.combined_log)
if (result.truncated) {
addNotification({
title: formatMessage(messages.shareTruncated),
type: 'warning',
})
}
shareUrl.value = result.url
try {
await navigator.clipboard.writeText(result.url)
addNotification({
title: formatMessage(messages.shareCopied),
type: 'success',
})
} catch (error) {
console.error('Failed to copy shared diagnostic URL', error)
addNotification({
title: formatMessage(messages.shareReady),
type: 'success',
})
}
} catch (error) {
console.error('Failed to share crash diagnostic', error)
addNotification({
title: formatMessage(messages.shareFailed),
type: 'error',
})
} finally {
sharing.value = false
}
}
async function copyShareUrl(): Promise<void> {
if (!shareUrl.value) return
try {
await navigator.clipboard.writeText(shareUrl.value)
addNotification({
title: formatMessage(messages.shareCopied),
type: 'success',
})
} catch (error) {
console.error('Failed to copy share URL', error)
}
}
function openAIAnalysis(): void {
if (!lastAnalysis?.combined_log) {
notifyNoLogContent()
return
}
aiModal.value?.show(payload.value.instance_id!)
}
async function refreshAIAvailability(): Promise<void> {
try {
const [settings, state] = await Promise.all([get_crash_analysis_ai_settings(), getAIState()])
const provider = state.providers.find((item) => item.provider_id === settings.provider_id)
aiAvailable.value =
settings.enabled &&
state.settings.enabled &&
!!provider?.enabled &&
provider.models.some((model) => model.id === settings.model_id && model.enabled)
} catch {
aiAvailable.value = false
}
}
async function handleProcessEvent(event: ProcessEvent): Promise<void> {
if (event.event === 'launched') {
activeRuns.set(event.instance_id, event.uuid)
clearCrashAnalysis(event.instance_id)
modChangesAvailable.value = false
return
}
if (event.event !== 'finished' || activeRuns.get(event.instance_id) !== event.uuid) return
if (!shouldShowMinecraftCrash(event.crashed)) {
activeRuns.delete(event.instance_id)
return
}
await new Promise((resolve) => setTimeout(resolve, 2000))
if (!mounted || activeRuns.get(event.instance_id) !== event.uuid) return
try {
const analysis = await refreshCrashAnalysis(event.instance_id).catch((error) => {
console.error('Failed to analyze finished Minecraft process', error)
return null
})
lastAnalysis = analysis
modChangesAvailable.value = !!analysis?.mod_changes.length
if (!mounted) return
const instance = await getInstance(event.instance_id).catch(() => null)
if (!mounted) return
show(
applyAnalysis(
{
instance_id: event.instance_id,
instance_name: instance?.name || 'Minecraft',
},
analysis,
),
)
} finally {
if (activeRuns.get(event.instance_id) === event.uuid) activeRuns.delete(event.instance_id)
}
}
onMounted(async () => {
mounted = true
void refreshAIAvailability()
const unlisten = await process_listener((event: ProcessEvent) => void handleProcessEvent(event))
if (!mounted) {
unlisten()
return
}
unlistenProcess = unlisten
})
onUnmounted(() => {
mounted = false
analysisVersion += 1
activeRuns.clear()
unlistenProcess?.()
})
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis })
</script>
<template>
<NewModal ref="modal" :header="title" fade="danger" max-width="560px">
<div class="flex flex-col gap-4">
<Admonition type="critical" :header="summary">
{{ body }}
</Admonition>
<p class="m-0 text-secondary">
{{ hint }}
</p>
<p v-if="showSupportHint" class="m-0 text-secondary">
{{ formatMessage(messages.supportHint) }}
</p>
<div v-if="shareUrl" class="flex items-center gap-2 rounded-lg bg-surface-2 p-3">
<ExternalIcon class="h-4 w-4 shrink-0 text-secondary" />
<a
:href="shareUrl"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 flex-1 truncate text-primary underline"
>
{{ shareUrl }}
</a>
<ButtonStyled type="outlined">
<button @click="copyShareUrl">
{{ formatMessage(messages.copyLink) }}
</button>
</ButtonStyled>
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled type="outlined">
<button :disabled="sharing" @click="shareDiagnostic">
{{
sharing
? formatMessage(messages.sharingDiagnostic)
: formatMessage(messages.shareDiagnostic)
}}
</button>
</ButtonStyled>
<ButtonStyled v-if="aiAvailable" color="brand">
<button @click="openAIAnalysis">
{{ formatMessage(messages.aiAnalyze) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="modChangesAvailable" type="outlined">
<button @click="openModChanges">
{{ formatMessage(messages.viewModChanges) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<CrashAIExplanationModal ref="aiModal" />
<CrashModChangesModal ref="modChangesModal" />
</template>