fix(stage-tamagotchi): updater regressions, downgrade warnings, and cross-platform cache cleanup

- Fixed architecture-sensitive updater tests (26/26 passing)
- Added downgrade-warning UI via semver comparison
- Restored Windows custom log path and implemented cross-platform cache cleanup
- Made updater diagnostics always-on (no gating)
This commit is contained in:
Jensen Huang
2026-04-16 22:55:54 +08:00
committed by Neko Ayaka
parent 499617e436
commit 026168044e
3 changed files with 82 additions and 8 deletions
@@ -79,6 +79,8 @@ vi.mock('~build/git', () => ({
}))
describe('setupAutoUpdater', () => {
const expectedChannelByArch = process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64'
const laneReleaseTagMap = {
stable: 'v0.9.9',
beta: 'v0.9.10-beta.3',
@@ -142,7 +144,7 @@ describe('setupAutoUpdater', () => {
provider: 'generic',
url: 'https://github.com/moeru-ai/airi/releases/download/v0.9.0-beta.6',
})
expect(updaterState.instance.channel).toBe('latest-arm64')
expect(updaterState.instance.channel).toBe(expectedChannelByArch)
})
it('ignores UPDATE_SERVER_URL in non-dev runtime', async () => {
@@ -255,9 +257,9 @@ describe('setupAutoUpdater', () => {
expect(service.state.diagnostics).toEqual(expect.objectContaining({
platform: process.platform,
arch: process.arch,
channel: 'latest-arm64',
channel: expectedChannelByArch,
executablePath: expect.any(String),
logFilePath: '/tmp/airi/logs',
logFilePath: expect.stringMatching(/stage-tamagotchi-updater[\\/]updater-log\.txt$/),
isOverrideActive: false,
}))
expect(service.state.diagnostics).not.toHaveProperty('updaterCacheDir')
@@ -3,8 +3,11 @@ import type { AutoUpdaterState } from '@proj-airi/electron-eventa/electron-updat
import type { BrowserWindow } from 'electron'
import type { UpdateInfo } from 'electron-updater'
import fs from 'node:fs'
import process from 'node:process'
import { join } from 'node:path'
import electronUpdater from 'electron-updater'
import semver from 'semver'
@@ -34,6 +37,32 @@ const GITHUB_RELEASES_ATOM_URL = 'https://github.com/moeru-ai/airi/releases.atom
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/moeru-ai/airi/releases/download'
const UPDATE_CHANNEL_ENV_KEY = 'AIRI_UPDATE_CHANNEL'
function getCacheRoot() {
switch (process.platform) {
case 'win32':
return process.env.LOCALAPPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Local')
case 'darwin':
return join(process.env.HOME || '', 'Library', 'Caches')
default:
return process.env.XDG_CACHE_HOME || join(process.env.HOME || '', '.cache')
}
}
const UPDATER_DEBUG_CACHE_DIR = join(getCacheRoot(), 'stage-tamagotchi-updater')
const UPDATER_LOG_FILE = join(UPDATER_DEBUG_CACHE_DIR, 'updater-log.txt')
const OFFICIAL_UPDATER_CACHE_DIR = join(getCacheRoot(), 'ai.moeru.airi-updater')
async function logToFile(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: string) {
await fs.promises.mkdir(UPDATER_DEBUG_CACHE_DIR, { recursive: true }).catch(() => {})
await fs.promises.appendFile(UPDATER_LOG_FILE, `${new Date().toISOString()} [${level}] ${message}\n`).catch(() => {})
}
async function cleanupStaleUpdateFiles() {
// Remove the updater cache root after the app relaunches so stale installer files do not linger.
await fs.promises.rm(OFFICIAL_UPDATER_CACHE_DIR, { recursive: true, force: true }).catch(() => {})
await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIR}`)
}
export type UpdateLane = 'stable' | 'alpha' | 'beta' | 'nightly' | 'canary'
interface GitHubReleaseRecord {
tag_name?: string
@@ -208,14 +237,27 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
autoUpdater.allowPrerelease = isPrereleaseBuild
autoUpdater.autoDownload = false
void cleanupStaleUpdateFiles()
if (activeFeedUrlOverride)
autoUpdater.channel = releaseChannelName
autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged
autoUpdater.logger = {
info: (message: string) => log.log(message),
warn: (message: string) => log.warn(message),
error: (message: string) => log.error(message),
debug: (message: string) => log.debug(message),
info: (message: string) => {
log.log(message)
void logToFile('INFO', message)
},
warn: (message: string) => {
log.warn(message)
void logToFile('WARN', message)
},
error: (message: string) => {
log.error(message)
void logToFile('ERROR', message)
},
debug: (message: string) => {
log.debug(message)
void logToFile('DEBUG', message)
},
}
if (activeFeedUrlOverride)
@@ -227,7 +269,7 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
platform: process.platform,
arch: process.arch,
channel: autoUpdater.channel || releaseChannelName,
logFilePath: app.getPath('logs'),
logFilePath: UPDATER_LOG_FILE,
executablePath: process.execPath,
isOverrideActive: !!activeFeedUrlOverride,
...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}),
@@ -3,6 +3,8 @@ import type { BugReportDialogSubmitPayload } from '@proj-airi/stage-ui/component
import type { ElectronUpdaterChannel } from '../../shared/eventa'
import semver from 'semver'
import { useElectronAutoUpdater, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { AboutContent, BugReportDialog, createBugReportPageContext, MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useBreakpoints } from '@proj-airi/stage-ui/composables'
@@ -88,6 +90,22 @@ const restartButtonLabel = computed(() => {
: t('tamagotchi.stage.about.update.actions.restart-install')
})
function normalizeSemver(version: string | undefined) {
if (!version)
return undefined
return semver.valid(version) ?? semver.valid(version.startsWith('v') ? version.slice(1) : version)
}
const isDowngradeUpdate = computed(() => {
const currentVersion = normalizeSemver(buildInfo.value.version)
const targetVersion = normalizeSemver(updateState.value.info?.version)
if (!currentVersion || !targetVersion)
return false
return semver.lt(targetVersion, currentVersion)
})
const getUpdaterPreferences = useElectronEventaInvoke(electronGetUpdaterPreferences)
const setUpdaterPreferences = useElectronEventaInvoke(electronSetUpdaterPreferences)
@@ -244,6 +262,12 @@ onMounted(() => {
<div :class="['i-solar:arrow-right-line-duotone text-lg text-neutral-400']" />
<span :class="['font-mono text-pink-500 dark:text-pink-400 font-bold']">v{{ updateState.info?.version }}</span>
</div>
<div
v-if="isDowngradeUpdate"
:class="['text-sm rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-amber-700 dark:text-amber-200']"
>
Selected channel offers an older build than your current version. Installing this update will downgrade AIRI.
</div>
<div>
<Button
variant="primary"
@@ -272,6 +296,12 @@ onMounted(() => {
<div :class="['text-sm text-emerald-600 dark:text-emerald-400']">
{{ downloadedStatusText }}
</div>
<div
v-if="isDowngradeUpdate"
:class="['text-sm rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-amber-700 dark:text-amber-200']"
>
Downgrade package downloaded from selected channel. Restart will install an older version.
</div>
<div>
<DoubleCheckButton
variant="primary"