refactor(stage-tamagotchi): improved about, better structure

This commit is contained in:
Neko Ayaka
2026-04-10 03:40:11 +08:00
parent b1a4eac605
commit 628fe28969
4 changed files with 199 additions and 112 deletions
@@ -411,6 +411,9 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
storedPreferredLane = lane
options.setStoredUpdateLane?.(lane)
resetPreparedFeedForLaneChange()
// Keep UI state consistent with the newly selected lane.
// A fresh check runs right after channel update from renderer.
broadcast({ status: 'idle' })
},
subscribe(callback) {
hooks.add(callback)
@@ -1,20 +1,26 @@
<script setup lang="ts">
import type { BugReportDialogSubmitPayload } from '@proj-airi/stage-ui/components'
import type { ElectronUpdaterChannel } from '../../shared/eventa'
import { useElectronAutoUpdater, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { AboutContent, MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { AboutContent, BugReportDialog, createBugReportPageContext, MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useBreakpoints } from '@proj-airi/stage-ui/composables'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { Button, DoubleCheckButton, FieldSelect, Progress } from '@proj-airi/ui'
import { Button, ContainerError, DoubleCheckButton, FieldSelect, Progress } from '@proj-airi/ui'
import { useClipboard } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { electronGetUpdaterPreferences, electronSetUpdaterPreferences } from '../../shared/eventa'
const analyticsStore = useSharedAnalyticsStore()
const { buildInfo } = storeToRefs(analyticsStore)
const { t } = useI18n()
const { copy: copyToClipboard, isSupported: isClipboardSupported } = useClipboard()
const {
state: updateState,
@@ -29,23 +35,47 @@ const isLatestVersion = computed(() => {
return updateState.value.status === 'not-available' && !isDisabled.value
})
const isError = computed(() => updateState.value.status === 'error')
const updaterErrorMessage = computed(() => {
const message = updateState.value.error?.message
if (typeof message === 'string')
return message
if (message == null)
return ''
try {
return JSON.stringify(message, null, 2)
}
catch {
return String(message)
}
})
const links = [
{ label: 'Home', href: 'https://airi.moeru.ai/docs/', icon: 'i-solar:home-smile-outline' },
{ label: 'Documentations', href: 'https://airi.moeru.ai/docs/en/docs/overview/', icon: 'i-solar:document-add-outline' },
{ label: 'GitHub', href: 'https://github.com/moeru-ai/airi', icon: 'i-simple-icons:github' },
{ labelKey: 'tamagotchi.stage.about.links.home', href: 'https://airi.moeru.ai/docs/', icon: 'i-solar:home-smile-outline' },
{ labelKey: 'tamagotchi.stage.about.links.documentation', href: 'https://airi.moeru.ai/docs/en/docs/overview/', icon: 'i-solar:document-add-outline' },
{ labelKey: 'tamagotchi.stage.about.links.github', href: 'https://github.com/moeru-ai/airi', icon: 'i-simple-icons:github' },
]
const showChangelog = ref(false)
const showBugReportDialog = ref(false)
const { isDesktop } = useBreakpoints()
const updateChannelOptions = ['auto', 'stable', 'alpha', 'beta', 'nightly', 'canary'] as const
const updateChannelSelectOptions = updateChannelOptions.map(channel => ({
label: channel,
const updateChannelSelectOptions = computed(() => updateChannelOptions.map(channel => ({
label: t(`tamagotchi.stage.about.update.channels.${channel}`),
value: channel,
}))
})))
type UpdateChannelOption = typeof updateChannelOptions[number]
const selectedUpdateChannel = ref<UpdateChannelOption>('auto')
const isUpdateChannelUpdating = ref(false)
const bugReportDescription = ref('')
const includeBugReportTriageContext = ref(false)
const uploadBugReportMediaFromLibrary = ref(false)
const bugReportScreenshotFiles = ref<File[] | undefined>(undefined)
const bugReportSending = ref(false)
const bugReportSubmitError = ref<unknown>(undefined)
const bugReportPageContext = ref(createBugReportPageContext())
const bugReportScreenshotAttached = ref(false)
const isWindowsUpdater = computed(() => {
return updateState.value.diagnostics?.platform === 'win32'
@@ -53,13 +83,15 @@ const isWindowsUpdater = computed(() => {
const downloadedStatusText = computed(() => {
if (isWindowsUpdater.value)
return `Update ready to install silently (v${updateState.value.info?.version}).`
return t('tamagotchi.stage.about.update.status.downloaded.windows', { version: updateState.value.info?.version ?? '' })
return `Update ready to install on restart (v${updateState.value.info?.version}).`
return t('tamagotchi.stage.about.update.status.downloaded.restart', { version: updateState.value.info?.version ?? '' })
})
const restartButtonLabel = computed(() => {
return isWindowsUpdater.value ? 'Restart to update silently' : 'Restart to install update'
return isWindowsUpdater.value
? t('tamagotchi.stage.about.update.actions.restart-silent')
: t('tamagotchi.stage.about.update.actions.restart-install')
})
const getUpdaterPreferences = useElectronEventaInvoke(electronGetUpdaterPreferences)
@@ -77,6 +109,45 @@ function confirmDownload() {
downloadUpdate()
}
function openBugReportDialog() {
const details = [
`Current version: ${buildInfo.value.version}`,
`Update status: ${updateState.value.status}`,
updaterErrorMessage.value ? `Error: ${updaterErrorMessage.value}` : '',
]
.filter(Boolean)
.join('\n')
bugReportDescription.value = details
bugReportSubmitError.value = undefined
showBugReportDialog.value = true
}
function onBugReportRequestTriageContext() {
includeBugReportTriageContext.value = true
bugReportScreenshotAttached.value = true
bugReportPageContext.value = createBugReportPageContext()
}
async function onBugReportSubmit(payload: BugReportDialogSubmitPayload) {
bugReportSending.value = true
bugReportSubmitError.value = undefined
try {
if (!isClipboardSupported.value)
throw new Error('Clipboard API is unavailable')
await copyToClipboard(payload.formattedReport)
showBugReportDialog.value = false
}
catch (error) {
bugReportSubmitError.value = error
}
finally {
bugReportSending.value = false
}
}
async function refreshUpdaterChannelPreference() {
const preferences = await getUpdaterPreferences()
selectedUpdateChannel.value = preferences?.channel ?? 'auto'
@@ -91,6 +162,7 @@ async function setUpdateChannelPreference(channel: UpdateChannelOption) {
const nextChannel = channel === 'auto' ? undefined : channel as ElectronUpdaterChannel
const preferences = await setUpdaterPreferences({ channel: nextChannel })
selectedUpdateChannel.value = preferences?.channel ?? 'auto'
await checkForUpdates()
}
finally {
isUpdateChannelUpdating.value = false
@@ -123,16 +195,20 @@ onMounted(() => {
]"
>
<div :class="['mx-auto max-w-[min(960px,calc(100%-2rem))]', 'px-6 py-20']">
<AboutContent title="Project" highlight="AIRI" subtitle="Desktop ver." />
<AboutContent
title="Project"
highlight="AIRI"
:subtitle="t('tamagotchi.stage.about.subtitle')"
/>
<!-- Main Content Card -->
<div :class="['mb-12', 'rounded-2xl', 'bg-white/50 dark:bg-black/20', 'p-6', 'backdrop-blur-sm']">
<FieldSelect
:model-value="selectedUpdateChannel"
:disabled="isUpdateChannelUpdating"
label="Update lane"
description="Choose which release lane updater checks against. Auto follows the current app prerelease lane."
placeholder="Choose update lane"
:disabled="isUpdateChannelUpdating || isBusy"
:label="t('tamagotchi.stage.about.update.lane.label')"
:description="t('tamagotchi.stage.about.update.lane.description')"
:placeholder="t('tamagotchi.stage.about.update.lane.placeholder')"
:options="updateChannelSelectOptions"
layout="vertical"
:class="['mb-6']"
@@ -143,7 +219,7 @@ onMounted(() => {
<div :class="['flex flex-wrap items-center justify-between gap-4', 'mb-6', 'border-b border-neutral-200/50 dark:border-neutral-800/50', 'pb-6']">
<div>
<div :class="['text-sm text-neutral-500 dark:text-neutral-400']">
Current Version
{{ t('tamagotchi.stage.about.current-version') }}
</div>
<div :class="['text-xl font-medium font-mono']">
{{ buildInfo.version }}
@@ -169,7 +245,7 @@ onMounted(() => {
variant="primary"
:loading="isBusy"
icon="i-solar:download-minimalistic-outline"
label="Download Update"
:label="t('tamagotchi.stage.about.update.actions.download')"
@click="handleDownloadClick()"
/>
</div>
@@ -178,7 +254,7 @@ onMounted(() => {
<!-- State: Downloading -->
<div v-else-if="updateState.status === 'downloading'" :class="['flex flex-col gap-2']">
<div :class="['flex justify-between text-sm']">
<span>Downloading update...</span>
<span>{{ t('tamagotchi.stage.about.update.status.downloading') }}</span>
<span :class="['font-mono']">{{ updateState.progress?.percent.toFixed(1) }}%</span>
</div>
<Progress :progress="updateState.progress?.percent ?? 0" />
@@ -199,10 +275,10 @@ onMounted(() => {
>
{{ restartButtonLabel }}
<template #confirm>
Confirm Restart
{{ t('tamagotchi.stage.about.update.actions.confirm-restart') }}
</template>
<template #cancel>
Cancel
{{ t('tamagotchi.stage.about.common.cancel') }}
</template>
</DoubleCheckButton>
</div>
@@ -210,11 +286,15 @@ onMounted(() => {
<!-- State: Idle, Checking, Error, Disabled, Not Available -->
<div v-else :class="['flex flex-col gap-4']">
<div v-if="isError" :class="['text-sm text-red-600 dark:text-red-400']">
Error: {{ updateState.error?.message }}
<div v-if="isError" :class="['flex flex-col gap-2']">
<ContainerError
:message="updaterErrorMessage"
height-preset="sm"
@feedback="openBugReportDialog"
/>
</div>
<div v-else-if="isLatestVersion" :class="['text-sm text-emerald-600 dark:text-emerald-400']">
Up to date (v{{ buildInfo.version }}).
{{ t('tamagotchi.stage.about.update.status.latest', { version: buildInfo.version }) }}
</div>
<div :class="['flex flex-wrap gap-2']">
@@ -223,7 +303,15 @@ onMounted(() => {
:loading="isBusy"
:disabled="isDisabled"
:icon="isLatestVersion ? 'i-solar:check-circle-outline' : isDisabled ? 'i-solar:forbidden-circle-outline' : 'i-solar:refresh-outline'"
:label="isBusy ? 'Checking...' : isLatestVersion ? 'Latest version' : isDisabled ? 'Updates disabled in Dev' : isError ? 'Retry Check' : 'Check for updates'"
:label="isBusy
? t('tamagotchi.stage.about.update.actions.checking')
: isLatestVersion
? t('tamagotchi.stage.about.update.actions.latest-version')
: isDisabled
? t('tamagotchi.stage.about.update.actions.disabled-dev')
: isError
? t('tamagotchi.stage.about.update.actions.retry-check')
: t('tamagotchi.stage.about.update.actions.check-for-updates')"
@click="checkForUpdates()"
/>
</div>
@@ -234,7 +322,7 @@ onMounted(() => {
<!-- Links -->
<div>
<div :class="['text-neutral-500 dark:text-neutral-400 mb-4']">
Links
{{ t('tamagotchi.stage.about.links.title') }}
</div>
<div :class="['flex flex-wrap gap-2']">
<a
@@ -247,7 +335,7 @@ onMounted(() => {
<Button
variant="secondary-muted"
:icon="link.icon"
:label="link.label"
:label="t(link.labelKey)"
size="sm"
/>
</a>
@@ -261,22 +349,22 @@ onMounted(() => {
<DialogOverlay class="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-[9999] max-h-[85vh] max-w-2xl w-[90vw] flex flex-col rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<DialogTitle class="mb-2 text-lg font-medium">
Update Available
{{ t('tamagotchi.stage.about.update.dialog.title') }}
</DialogTitle>
<DialogDescription class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
A new version (v{{ updateState.info?.version }}) is available.
{{ t('tamagotchi.stage.about.update.dialog.description', { version: updateState.info?.version }) }}
</DialogDescription>
<div class="min-h-0 flex-1 overflow-y-auto border border-neutral-200 rounded-lg bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-950/50">
<MarkdownRenderer :content="releaseNotesContent || '_No release notes provided._'" class="text-sm" />
<MarkdownRenderer :content="releaseNotesContent || t('tamagotchi.stage.about.update.dialog.no-release-notes-markdown')" class="text-sm" />
</div>
<div class="mt-6 flex justify-end gap-3">
<Button variant="secondary" @click="showChangelog = false">
Cancel
{{ t('tamagotchi.stage.about.common.cancel') }}
</Button>
<Button variant="primary" icon="i-solar:download-minimalistic-outline" @click="confirmDownload">
Confirm Download
{{ t('tamagotchi.stage.about.update.actions.confirm-download') }}
</Button>
</div>
</DialogContent>
@@ -291,28 +379,42 @@ onMounted(() => {
<div class="flex flex-1 flex-col rounded-t-2xl bg-white p-4 dark:bg-neutral-900">
<DrawerHandle class="mx-auto mb-4 h-1.5 w-12 rounded-full bg-neutral-300 dark:bg-neutral-700" />
<DrawerTitle class="mb-2 text-lg font-medium">
Update Available
{{ t('tamagotchi.stage.about.update.dialog.title') }}
</DrawerTitle>
<DrawerDescription class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
A new version (v{{ updateState.info?.version }}) is available.
{{ t('tamagotchi.stage.about.update.dialog.description', { version: updateState.info?.version }) }}
</DrawerDescription>
<div class="min-h-0 flex-1 overflow-y-auto border border-neutral-200 rounded-lg bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-950/50">
<MarkdownRenderer :content="releaseNotesContent || '_No release notes provided._'" class="text-sm" />
<MarkdownRenderer :content="releaseNotesContent || t('tamagotchi.stage.about.update.dialog.no-release-notes-markdown')" class="text-sm" />
</div>
<div class="mt-4 flex gap-3">
<Button variant="secondary" block @click="showChangelog = false">
Cancel
{{ t('tamagotchi.stage.about.common.cancel') }}
</Button>
<Button variant="primary" block icon="i-solar:download-minimalistic-outline" @click="confirmDownload">
Download
{{ t('tamagotchi.stage.about.update.actions.download-short') }}
</Button>
</div>
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
<BugReportDialog
v-model="showBugReportDialog"
v-model:description="bugReportDescription"
v-model:include-triage-context="includeBugReportTriageContext"
v-model:upload-media-from-library="uploadBugReportMediaFromLibrary"
v-model:screenshot-files="bugReportScreenshotFiles"
:sending="bugReportSending"
:submit-error="bugReportSubmitError"
:page-context="bugReportPageContext"
:screenshot-attached="bugReportScreenshotAttached"
@request-triage-context="onBugReportRequestTriageContext"
@submit="onBugReportSubmit"
/>
</div>
</template>
@@ -1,11 +1,8 @@
<script setup lang="ts">
import { useElectronAutoUpdater } from '@proj-airi/electron-vueuse'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { Button, Progress } from '@proj-airi/ui'
import { computed } from 'vue'
const settings = useSettings()
const {
state: updateState,
isBusy,
@@ -36,74 +33,69 @@ const diagnosticsEntries = computed(() => {
<template>
<div :class="['flex flex-col gap-4', 'pb-8']">
<div
v-if="!settings.inspectUpdaterDiagnostics"
:class="['rounded-2xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-100']"
>
Enable "Inspect updater diagnostics" from Settings > System > Developer to inspect updater internals here.
<div :class="['flex flex-wrap gap-2']">
<Button
variant="secondary"
:loading="isBusy"
icon="i-solar:refresh-outline"
label="Check for updates"
@click="checkForUpdates()"
/>
<Button
variant="secondary"
:disabled="updateState.status !== 'available'"
icon="i-solar:download-minimalistic-outline"
label="Download update"
@click="downloadUpdate()"
/>
<Button
variant="secondary"
:disabled="updateState.status !== 'downloaded'"
icon="i-solar:restart-bold-duotone"
label="Restart to install"
@click="quitAndInstall()"
/>
</div>
<template v-else>
<div :class="['flex flex-wrap gap-2']">
<Button
variant="secondary"
:loading="isBusy"
icon="i-solar:refresh-outline"
label="Check for updates"
@click="checkForUpdates()"
/>
<Button
variant="secondary"
:disabled="updateState.status !== 'available'"
icon="i-solar:download-minimalistic-outline"
label="Download update"
@click="downloadUpdate()"
/>
<Button
variant="secondary"
:disabled="updateState.status !== 'downloaded'"
icon="i-solar:restart-bold-duotone"
label="Restart to install"
@click="quitAndInstall()"
/>
<div v-if="updateState.status === 'downloading'" :class="['flex flex-col gap-2']">
<div :class="['flex items-center justify-between text-sm text-neutral-600 dark:text-neutral-300']">
<span>Downloading update</span>
<span>{{ updateState.progress?.percent.toFixed(1) }}%</span>
</div>
<Progress :progress="updateState.progress?.percent ?? 0" />
</div>
<div
v-if="updateState.status === 'error'"
:class="[
'rounded-2xl border-2 p-4 text-sm whitespace-pre-wrap',
'border-red-300/60 bg-red-50 text-red-700',
'dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-100',
]"
>
{{ updateState.error?.message }}
</div>
<section :class="['rounded-2xl border-2 p-4', 'border-neutral-200 bg-white/70 dark:border-neutral-700/60 dark:bg-neutral-950/40']">
<div :class="['mb-3 text-sm text-neutral-600 dark:text-neutral-400']">
Updater diagnostics
</div>
<div v-if="updateState.status === 'downloading'" :class="['flex flex-col gap-2']">
<div :class="['flex items-center justify-between text-sm text-neutral-300']">
<span>Downloading update</span>
<span>{{ updateState.progress?.percent.toFixed(1) }}%</span>
</div>
<Progress :progress="updateState.progress?.percent ?? 0" />
</div>
<div
v-if="updateState.status === 'error'"
:class="['rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-100 whitespace-pre-wrap']"
>
{{ updateState.error?.message }}
</div>
<section :class="['rounded-2xl border border-neutral-700/60', 'bg-neutral-950/40 p-4']">
<div :class="['mb-3 text-sm text-neutral-400']">
Updater diagnostics
</div>
<div :class="['grid gap-2 text-sm text-neutral-100']">
<div
v-for="[label, value] in diagnosticsEntries"
:key="label"
:class="['grid gap-1 md:grid-cols-[180px_minmax(0,1fr)]']"
>
<div :class="['text-neutral-400']">
{{ label }}
</div>
<div :class="['font-mono break-words']">
{{ value }}
</div>
<div :class="['grid gap-2 text-sm text-neutral-800 dark:text-neutral-100']">
<div
v-for="[label, value] in diagnosticsEntries"
:key="label"
:class="['grid gap-1 md:grid-cols-[180px_minmax(0,1fr)]']"
>
<div :class="['text-neutral-500 dark:text-neutral-400']">
{{ label }}
</div>
<div :class="['font-mono break-words']">
{{ value }}
</div>
</div>
</section>
</template>
</div>
</section>
</div>
</template>
@@ -143,16 +143,6 @@ const openDevtoolsWindow = useElectronEventaInvoke(electronOpenDevtoolsWindow)
description="settings.animations.use-page-specific-transitions.description"
transition="all ease-in-out duration-250"
/>
<CheckBar
v-model="settings.inspectUpdaterDiagnostics"
mb-2
icon-on="i-solar:bug-bold-duotone"
icon-off="i-solar:bug-minimalistic-outline"
text="Inspect updater diagnostics"
description="Show detailed updater diagnostics in the developer updater page."
transition="all ease-in-out duration-250"
/>
<div flex="~ col gap-4" mt-2 pb-12>
<IconItem
v-for="(item, index) in menu"