feat(stage-tamagotchi): improve updater cleanup lane and admin diagnostics
This commit is contained in:
@@ -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']">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user