feat(stage-tamagotchi): improve updater cleanup lane and admin diagnostics

This commit is contained in:
Jensen Huang
2026-04-16 22:58:10 +08:00
committed by Neko Ayaka
parent 1a11409b1e
commit c14e9547d9
5 changed files with 82 additions and 10 deletions
@@ -82,13 +82,14 @@ describe('setupAutoUpdater', () => {
const expectedChannelByArch = process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64'
const laneReleaseTagMap = {
latest: 'v0.9.12-nightly.7',
stable: 'v0.9.9',
beta: 'v0.9.10-beta.3',
alpha: 'v0.9.11-alpha.4',
nightly: 'v0.9.12-nightly.7',
} as const
const bundleVersions = ['0.9.0', '0.9.0-beta.4', '0.9.0-alpha.2'] as const
const laneMatrix = ['stable', 'beta', 'alpha', 'nightly'] as const
const laneMatrix = ['latest', 'stable', 'beta', 'alpha', 'nightly'] as const
const defaultReleases = [
{ tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
@@ -6,7 +6,7 @@ import type { UpdateInfo } from 'electron-updater'
import fs from 'node:fs'
import process from 'node:process'
import { join } from 'node:path'
import { dirname, join, normalize } from 'node:path'
import electronUpdater from 'electron-updater'
import semver from 'semver'
@@ -43,9 +43,25 @@ function getCacheRoot() {
return app.getPath('cache' as Parameters<typeof app.getPath>[0])
}
function getLegacyCacheRoot() {
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')
const LEGACY_OFFICIAL_UPDATER_CACHE_DIR = join(getLegacyCacheRoot(), 'ai.moeru.airi-updater')
const OFFICIAL_UPDATER_CACHE_DIRS = Array.from(new Set([
OFFICIAL_UPDATER_CACHE_DIR,
LEGACY_OFFICIAL_UPDATER_CACHE_DIR,
]))
async function logToFile(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: string) {
await fs.promises.mkdir(UPDATER_DEBUG_CACHE_DIR, { recursive: true }).catch(() => {})
@@ -53,12 +69,12 @@ async function logToFile(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: st
}
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}`)
// Remove both current and legacy updater cache roots so stale installers do not linger.
await Promise.allSettled(OFFICIAL_UPDATER_CACHE_DIRS.map(cacheDir => fs.promises.rm(cacheDir, { recursive: true, force: true })))
await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIRS.join(', ')}`)
}
export type UpdateLane = 'stable' | 'alpha' | 'beta' | 'nightly' | 'canary'
export type UpdateLane = 'latest' | 'stable' | 'alpha' | 'beta' | 'nightly' | 'canary'
interface GitHubReleaseRecord {
tag_name?: string
draft?: boolean
@@ -81,6 +97,7 @@ function normalizeLane(value: string | undefined): UpdateLane | undefined {
switch (value.toLowerCase()) {
case 'stable':
case 'latest':
case 'alpha':
case 'beta':
case 'nightly':
@@ -109,6 +126,9 @@ function isTagInLane(tag: string, lane: UpdateLane) {
if (!version)
return false
if (lane === 'latest')
return true
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
if (lane === 'stable')
return !prerelease
@@ -116,6 +136,33 @@ function isTagInLane(tag: string, lane: UpdateLane) {
return prerelease === lane
}
function isPathInside(parentPath: string, targetPath: string) {
const normalizedParent = normalize(parentPath)
const normalizedTarget = normalize(targetPath)
const parentWithSeparator = normalizedParent.endsWith('\\') ? normalizedParent : `${normalizedParent}\\`
return normalizedTarget === normalizedParent || normalizedTarget.startsWith(parentWithSeparator)
}
function getWindowsProtectedInstallRoots() {
return [
process.env.ProgramFiles,
process.env['ProgramFiles(x86)'],
process.env.ProgramW6432,
process.env.SystemRoot,
process.env.windir,
]
.filter((value): value is string => Boolean(value))
.map(value => normalize(value))
}
function requiresAdminForInstallPath(executablePath: string) {
if (!isWindows)
return false
const installDirectory = dirname(executablePath)
return getWindowsProtectedInstallRoots().some(root => isPathInside(root, installDirectory))
}
function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLane) {
const candidates = releases
.filter(release => !release.draft && typeof release.tag_name === 'string' && isTagInLane(release.tag_name, lane))
@@ -266,6 +313,8 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
channel: autoUpdater.channel || releaseChannelName,
logFilePath: UPDATER_LOG_FILE,
executablePath: process.execPath,
installDirectory: dirname(process.execPath),
requiresAdminForInstallPath: requiresAdminForInstallPath(process.execPath),
isOverrideActive: !!activeFeedUrlOverride,
...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}),
},
@@ -56,12 +56,14 @@ const updaterErrorMessage = computed(() => {
const showChangelog = ref(false)
const showBugReportDialog = ref(false)
const { isDesktop } = useBreakpoints()
const updateChannelOptions = ['auto', 'stable', 'alpha', 'beta', 'nightly', 'canary'] as const
const updateChannelOptions = ['auto', 'latest', 'stable', 'alpha', 'beta', 'nightly', 'canary'] as const
type UpdateChannelOption = typeof updateChannelOptions[number]
const updateChannelSelectOptions = computed(() => updateChannelOptions.map(channel => ({
label: t(`tamagotchi.stage.about.update.channels.${channel}`),
label: channel === 'latest'
? 'Latest (any release)'
: 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('')
@@ -90,6 +92,14 @@ const restartButtonLabel = computed(() => {
: t('tamagotchi.stage.about.update.actions.restart-install')
})
const requiresWindowsAdminUpdatePrompt = computed(() => {
return isWindowsUpdater.value && updateState.value.diagnostics?.requiresAdminForInstallPath === true
})
const updateInstallDirectory = computed(() => {
return updateState.value.diagnostics?.installDirectory ?? updateState.value.diagnostics?.executablePath ?? ''
})
function normalizeSemver(version: string | undefined) {
if (!version)
return undefined
@@ -255,6 +265,16 @@ onMounted(() => {
<!-- Update Logic -->
<div :class="['flex flex-col gap-4']">
<div
v-if="requiresWindowsAdminUpdatePrompt"
:class="['text-sm rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-amber-700 dark:text-amber-200']"
>
AIRI is installed in a protected Windows folder. Update install may require a UAC admin prompt.
<div :class="['mt-1 text-xs break-all text-amber-700/80 dark:text-amber-100/80']">
Path: {{ updateInstallDirectory }}
</div>
</div>
<!-- State: Available -->
<div v-if="updateState.status === 'available'" :class="['flex flex-col gap-4']">
<div :class="['text-sm flex flex-wrap items-center gap-2']">
+1 -1
View File
@@ -42,7 +42,7 @@ export const electronGetServerChannelConfig = defineInvokeEventa<ElectronServerC
export const electronApplyServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig, Partial<ElectronServerChannelConfig>>('eventa:invoke:electron:server-channel:apply-config')
export const electronGetServerChannelQrPayload = defineInvokeEventa<ServerChannelQrPayload>('eventa:invoke:electron:server-channel:get-qr-payload')
export type ElectronUpdaterChannel = 'stable' | 'alpha' | 'beta' | 'nightly' | 'canary'
export type ElectronUpdaterChannel = 'latest' | 'stable' | 'alpha' | 'beta' | 'nightly' | 'canary'
export interface ElectronUpdaterPreferences {
channel?: ElectronUpdaterChannel
@@ -30,6 +30,8 @@ export interface AutoUpdaterDiagnostics {
feedUrl?: string
logFilePath: string
executablePath: string
installDirectory: string
requiresAdminForInstallPath: boolean
isOverrideActive: boolean
}