fix(stage-tamagotchi): autoUpdater fix with channels

This commit is contained in:
Neko Ayaka
2026-04-08 03:32:07 +08:00
parent 4179365baa
commit c2dba69a8b
6 changed files with 329 additions and 35 deletions
@@ -1,9 +1,10 @@
import { object, optional, string } from 'valibot'
import { object, optional, picklist, string } from 'valibot'
import { createConfig } from '../libs/electron/persistence'
export const globalAppConfigSchema = object({
language: optional(string(), 'en'),
updateChannel: optional(picklist(['stable', 'alpha', 'beta', 'nightly', 'canary'])),
})
export function createGlobalAppConfig() {
+13 -1
View File
@@ -101,7 +101,19 @@ app.whenReady().then(async () => {
const appConfig = injeca.provide('configs:app', () => createGlobalAppConfig())
const electronApp = injeca.provide('host:electron:app', () => app)
const autoUpdater = injeca.provide('services:auto-updater', () => setupAutoUpdater())
const autoUpdater = injeca.provide('services:auto-updater', {
dependsOn: { appConfig },
build: ({ dependsOn }) => setupAutoUpdater({
getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel,
setStoredUpdateLane: (lane) => {
const currentConfig = dependsOn.appConfig.get()
dependsOn.appConfig.update({
language: currentConfig?.language ?? 'en',
updateChannel: lane,
})
},
}),
})
const i18n = injeca.provide('libs:i18n', {
dependsOn: { appConfig },
@@ -11,6 +11,10 @@ const isDevState = vi.hoisted(() => ({
value: false,
}))
const stdEnvState = vi.hoisted(() => ({
isWindows: false,
}))
const updaterState = vi.hoisted(() => ({
instance: createUpdaterMock(),
}))
@@ -42,6 +46,12 @@ vi.mock('@electron-toolkit/utils', () => ({
},
}))
vi.mock('std-env', () => ({
get isWindows() {
return stdEnvState.isWindows
},
}))
vi.mock('@guiiai/logg', () => ({
useLogg: () => ({
useGlobalConfig: () => ({
@@ -69,6 +79,21 @@ vi.mock('~build/git', () => ({
}))
describe('setupAutoUpdater', () => {
const defaultReleases = [
{ tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
]
function mockGitHubReleasesFetch(releases = defaultReleases) {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => releases,
})
vi.stubGlobal('fetch', fetchSpy)
return fetchSpy
}
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
@@ -76,41 +101,49 @@ describe('setupAutoUpdater', () => {
appMock.getVersion.mockReturnValue('0.9.0-beta.4')
appMock.getPath.mockImplementation((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`)
isDevState.value = false
stdEnvState.isWindows = false
delete process.env.UPDATE_SERVER_URL
delete process.env.AIRI_UPDATE_CHANNEL
mockGitHubReleasesFetch()
})
it('does not query the GitHub Releases API during setup or manual checks', async () => {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => [],
})
vi.stubGlobal('fetch', fetchSpy)
it('resolves release tag from GitHub API and configures generic provider for checks', async () => {
const fetchSpy = mockGitHubReleasesFetch([
{ tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
{ tag_name: 'v0.9.0-beta.5', draft: false, prerelease: true },
])
const { setupAutoUpdater } = await import('./auto-updater')
const service = setupAutoUpdater()
await Promise.resolve()
await service.checkForUpdates()
expect(fetchSpy).not.toHaveBeenCalled()
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'https://github.com/moeru-ai/airi/releases/download/v0.9.0-beta.6',
})
expect(updaterState.instance.channel).toBe('latest-arm64')
})
it('only uses setFeedURL when an explicit update server override is provided', async () => {
it('ignores UPDATE_SERVER_URL in non-dev runtime', async () => {
process.env.UPDATE_SERVER_URL = 'http://localhost:8787/stable'
const { setupAutoUpdater } = await import('./auto-updater')
setupAutoUpdater()
const service = setupAutoUpdater()
await service.checkForUpdates()
expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'http://localhost:8787/stable',
url: 'https://github.com/moeru-ai/airi/releases/download/v0.9.0-beta.6',
})
})
it('uses the real updater in dev mode when an explicit update server override is provided', async () => {
it('uses UPDATE_SERVER_URL only in dev mode for update-test harness', async () => {
isDevState.value = true
process.env.UPDATE_SERVER_URL = 'http://localhost:8787/stable'
const fetchSpy = mockGitHubReleasesFetch()
const { setupAutoUpdater } = await import('./auto-updater')
setupAutoUpdater()
@@ -119,6 +152,25 @@ describe('setupAutoUpdater', () => {
url: 'http://localhost:8787/stable',
})
expect(updaterState.instance.forceDevUpdateConfig).toBe(true)
expect(fetchSpy).not.toHaveBeenCalled()
})
it('supports explicit stable lane selection for future dynamic channel switching', async () => {
process.env.AIRI_UPDATE_CHANNEL = 'stable'
mockGitHubReleasesFetch([
{ tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
{ tag_name: 'v0.8.9', draft: false, prerelease: false },
{ tag_name: 'v0.8.8', draft: false, prerelease: false },
])
const { setupAutoUpdater } = await import('./auto-updater')
const service = setupAutoUpdater()
await service.checkForUpdates()
expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'https://github.com/moeru-ai/airi/releases/download/v0.8.9',
})
})
it('reports only authoritative diagnostics fields', async () => {
@@ -149,21 +201,16 @@ describe('setupAutoUpdater', () => {
})
it('uses silent relaunch install on Windows only', async () => {
const originalPlatform = process.platform
vi.stubEnv('TEST_PLATFORM', '')
stdEnvState.isWindows = true
const { setupAutoUpdater } = await import('./auto-updater')
const service = setupAutoUpdater()
Object.defineProperty(process, 'platform', { value: 'win32' })
await service.quitAndInstall()
expect(updaterState.instance.quitAndInstall).toHaveBeenCalledWith(true, true)
stdEnvState.isWindows = false
updaterState.instance.quitAndInstall.mockClear()
Object.defineProperty(process, 'platform', { value: 'darwin' })
await service.quitAndInstall()
expect(updaterState.instance.quitAndInstall).toHaveBeenCalledWith()
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
})
@@ -20,6 +20,8 @@ import { isWindows } from 'std-env'
import {
autoUpdater as autoUpdaterEventa,
electronAutoUpdaterStateChanged,
electronGetUpdaterPreferences,
electronSetUpdaterPreferences,
} from '../../../shared/eventa'
import { MockAutoUpdater } from './mock-auto-updater'
@@ -27,11 +29,82 @@ function getReleaseChannelName() {
return process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64'
}
const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/moeru-ai/airi/releases?per_page=100'
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/moeru-ai/airi/releases/download'
const UPDATE_CHANNEL_ENV_KEY = 'AIRI_UPDATE_CHANNEL'
export type UpdateLane = 'stable' | 'alpha' | 'beta' | 'nightly' | 'canary'
interface GitHubReleaseRecord {
tag_name?: string
draft?: boolean
prerelease?: boolean
}
function getUpdateServerOverride() {
// NOTICE: UPDATE_SERVER_URL is intentionally development-only for local update-test harness.
// Production update routing must not depend on this variable.
if (!is.dev)
return undefined
const value = process.env.UPDATE_SERVER_URL?.trim()
return value || undefined
}
function normalizeLane(value: string | undefined): UpdateLane | undefined {
if (!value)
return undefined
switch (value.toLowerCase()) {
case 'stable':
case 'alpha':
case 'beta':
case 'nightly':
case 'canary':
return value.toLowerCase() as UpdateLane
default:
return undefined
}
}
function laneFromVersion(version: string): UpdateLane {
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
return normalizeLane(prerelease) ?? 'stable'
}
function getPreferredUpdateLane(params: { version: string, storedLane?: UpdateLane }): UpdateLane {
return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? params.storedLane ?? laneFromVersion(params.version)
}
function getSemverFromTag(tag: string) {
return semver.valid(tag) ?? semver.valid(tag.startsWith('v') ? tag.slice(1) : tag)
}
function isTagInLane(tag: string, lane: UpdateLane) {
const version = getSemverFromTag(tag)
if (!version)
return false
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
if (lane === 'stable')
return !prerelease
return prerelease === lane
}
function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLane) {
const candidates = releases
.filter(release => !release.draft && typeof release.tag_name === 'string' && isTagInLane(release.tag_name, lane))
.map((release) => {
const tag = release.tag_name as string
const version = getSemverFromTag(tag)
return version ? { tag, version } : null
})
.filter(Boolean) as Array<{ tag: string, version: string }>
candidates.sort((a, b) => semver.rcompare(a.version, b.version))
return candidates[0]?.tag
}
export interface AppUpdaterLike {
on: (event: string, listener: (...args: any[]) => void) => any
checkForUpdates: () => Promise<any>
@@ -62,23 +135,37 @@ export interface AutoUpdater {
checkForUpdates: () => Promise<void>
downloadUpdate: () => Promise<void>
quitAndInstall: () => Promise<void>
getPreferredUpdateLane: () => UpdateLane | undefined
setPreferredUpdateLane: (lane: UpdateLane | undefined) => Promise<void>
subscribe: (callback: (state: AutoUpdaterState) => void) => () => void
}
export interface AutoUpdaterOptions {
getStoredUpdateLane?: () => UpdateLane | undefined
setStoredUpdateLane?: (lane: UpdateLane | undefined) => void
}
function isPrereleaseVersion(version: string) {
return (semver.prerelease(version)?.length ?? 0) > 0
}
export function setupAutoUpdater(): AutoUpdater {
export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater {
const semaphore = new Semaphore(1)
const isPrereleaseBuild = isPrereleaseVersion(app.getVersion())
const appVersion = app.getVersion()
const isPrereleaseBuild = isPrereleaseVersion(appVersion)
const log = useLogg('auto-updater').useGlobalConfig()
const autoUpdater = fromImported()
const feedUrlOverride = getUpdateServerOverride()
let storedPreferredLane = options.getStoredUpdateLane?.()
const releaseChannelName = getReleaseChannelName()
let activeFeedUrlOverride = feedUrlOverride
let resolvedReleaseTag: string | undefined
let prepareFeedPromise: Promise<void> | undefined
autoUpdater.allowPrerelease = isPrereleaseBuild
autoUpdater.autoDownload = false
autoUpdater.channel = getReleaseChannelName()
if (activeFeedUrlOverride)
autoUpdater.channel = releaseChannelName
autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged
autoUpdater.logger = {
info: (message: string) => log.log(message),
@@ -87,19 +174,19 @@ export function setupAutoUpdater(): AutoUpdater {
debug: (message: string) => log.debug(message),
}
if (feedUrlOverride)
autoUpdater.setFeedURL?.({ provider: 'generic', url: feedUrlOverride })
if (activeFeedUrlOverride)
autoUpdater.setFeedURL?.({ provider: 'generic', url: activeFeedUrlOverride })
const withDiagnostics = (next: AutoUpdaterState): AutoUpdaterState => ({
...next,
diagnostics: {
platform: process.platform,
arch: process.arch,
channel: autoUpdater.channel || getReleaseChannelName(),
channel: autoUpdater.channel || releaseChannelName,
logFilePath: app.getPath('logs'),
executablePath: process.execPath,
isOverrideActive: !!feedUrlOverride,
...(feedUrlOverride ? { feedUrl: feedUrlOverride } : {}),
isOverrideActive: !!activeFeedUrlOverride,
...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}),
},
})
@@ -127,6 +214,74 @@ export function setupAutoUpdater(): AutoUpdater {
log.withError(error).error(reason)
}
function applyGenericFeedOverride(url: string, reason: string) {
activeFeedUrlOverride = url
autoUpdater.channel = releaseChannelName
autoUpdater.setFeedURL?.({ provider: 'generic', url })
log.warn(`[auto-updater] applied generic feed override (${reason}): ${url}`)
}
function resetPreparedFeedForLaneChange() {
if (feedUrlOverride)
return
activeFeedUrlOverride = undefined
resolvedReleaseTag = undefined
prepareFeedPromise = undefined
autoUpdater.channel = undefined
}
async function resolveGitHubReleaseTagForLane(lane: UpdateLane) {
const response = await fetch(GITHUB_RELEASES_API_URL, {
headers: {
accept: 'application/vnd.github+json',
},
})
if (!response.ok)
throw new Error(`Failed to fetch GitHub releases (${response.status} ${response.statusText})`)
const payload = await response.json()
if (!Array.isArray(payload))
throw new Error('Unexpected GitHub releases payload shape')
const tag = selectLatestTagForLane(payload as GitHubReleaseRecord[], lane)
if (!tag)
throw new Error(`No GitHub release found for update lane "${lane}"`)
return tag
}
async function prepareGitHubGenericFeed() {
if (activeFeedUrlOverride)
return
if (resolvedReleaseTag)
return
if (prepareFeedPromise) {
await prepareFeedPromise
return
}
prepareFeedPromise = (async () => {
const preferredLane = getPreferredUpdateLane({ version: appVersion, storedLane: storedPreferredLane })
const tag = await resolveGitHubReleaseTagForLane(preferredLane)
resolvedReleaseTag = tag
applyGenericFeedOverride(`${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${tag}`, `github-release-lane:${preferredLane}`)
})()
try {
await prepareFeedPromise
}
finally {
prepareFeedPromise = undefined
}
}
async function checkForUpdatesWithPreparedFeed() {
await prepareGitHubGenericFeed()
await autoUpdater.checkForUpdates()
}
autoUpdater.on('error', error => broadcastUpdaterError(error, 'autoUpdater error'))
autoUpdater.on('checking-for-update', () => broadcast({ status: 'checking' }))
autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ status: 'available', info }))
@@ -150,8 +305,7 @@ export function setupAutoUpdater(): AutoUpdater {
},
}))
void autoUpdater
.checkForUpdates()
void checkForUpdatesWithPreparedFeed()
.catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
return {
@@ -160,7 +314,7 @@ export function setupAutoUpdater(): AutoUpdater {
},
async checkForUpdates() {
broadcast({ status: 'checking' })
await autoUpdater.checkForUpdates().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
await checkForUpdatesWithPreparedFeed().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
},
async downloadUpdate() {
if (state.status === 'downloading' || state.status === 'downloaded')
@@ -188,6 +342,17 @@ export function setupAutoUpdater(): AutoUpdater {
semaphore.release()
}
},
getPreferredUpdateLane() {
return storedPreferredLane
},
async setPreferredUpdateLane(lane) {
if (storedPreferredLane === lane)
return
storedPreferredLane = lane
options.setStoredUpdateLane?.(lane)
resetPreparedFeedForLaneChange()
},
subscribe(callback) {
hooks.add(callback)
@@ -226,6 +391,15 @@ export function createAutoUpdaterService(params: { context: MainContext, window:
await service.downloadUpdate()
return service.state
}),
defineInvokeHandler(context, electronGetUpdaterPreferences, async () => ({
channel: service.getPreferredUpdateLane(),
})),
defineInvokeHandler(context, electronSetUpdaterPreferences, async (payload) => {
await service.setPreferredUpdateLane(payload?.channel)
return {
channel: service.getPreferredUpdateLane(),
}
}),
defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, async () => {
await service.quitAndInstall()
}),
@@ -1,13 +1,17 @@
<script setup lang="ts">
import { useElectronAutoUpdater } from '@proj-airi/electron-vueuse'
import type { ElectronUpdaterChannel } from '../../shared/eventa'
import { useElectronAutoUpdater, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { AboutContent, 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, Progress } from '@proj-airi/ui'
import { Button, DoubleCheckButton, FieldSelect, Progress } from '@proj-airi/ui'
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, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { electronGetUpdaterPreferences, electronSetUpdaterPreferences } from '../../shared/eventa'
const analyticsStore = useSharedAnalyticsStore()
const { buildInfo } = storeToRefs(analyticsStore)
@@ -34,6 +38,14 @@ const links = [
const showChangelog = ref(false)
const { isDesktop } = useBreakpoints()
const updateChannelOptions = ['auto', 'stable', 'alpha', 'beta', 'nightly', 'canary'] as const
const updateChannelSelectOptions = updateChannelOptions.map(channel => ({
label: channel,
value: channel,
}))
type UpdateChannelOption = typeof updateChannelOptions[number]
const selectedUpdateChannel = ref<UpdateChannelOption>('auto')
const isUpdateChannelUpdating = ref(false)
const isWindowsUpdater = computed(() => {
return updateState.value.diagnostics?.platform === 'win32'
@@ -50,6 +62,9 @@ const restartButtonLabel = computed(() => {
return isWindowsUpdater.value ? 'Restart to update silently' : 'Restart to install update'
})
const getUpdaterPreferences = useElectronEventaInvoke(electronGetUpdaterPreferences)
const setUpdaterPreferences = useElectronEventaInvoke(electronSetUpdaterPreferences)
function handleDownloadClick() {
if (updateState.value.info?.releaseNotes)
showChangelog.value = true
@@ -62,6 +77,26 @@ function confirmDownload() {
downloadUpdate()
}
async function refreshUpdaterChannelPreference() {
const preferences = await getUpdaterPreferences()
selectedUpdateChannel.value = preferences?.channel ?? 'auto'
}
async function setUpdateChannelPreference(channel: UpdateChannelOption) {
if (isUpdateChannelUpdating.value)
return
isUpdateChannelUpdating.value = true
try {
const nextChannel = channel === 'auto' ? undefined : channel as ElectronUpdaterChannel
const preferences = await setUpdaterPreferences({ channel: nextChannel })
selectedUpdateChannel.value = preferences?.channel ?? 'auto'
}
finally {
isUpdateChannelUpdating.value = false
}
}
// Ensure releaseNotes is a string for the renderer
const releaseNotesContent = computed(() => {
const notes = updateState.value.info?.releaseNotes
@@ -70,6 +105,10 @@ const releaseNotesContent = computed(() => {
}
return typeof notes === 'string' ? notes : ''
})
onMounted(() => {
void refreshUpdaterChannelPreference()
})
</script>
<template>
@@ -88,6 +127,18 @@ const releaseNotesContent = computed(() => {
<!-- 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"
:options="updateChannelSelectOptions"
layout="vertical"
:class="['mb-6']"
@update:model-value="setUpdateChannelPreference($event as UpdateChannelOption)"
/>
<!-- Build Info -->
<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>
@@ -33,6 +33,15 @@ 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 interface ElectronUpdaterPreferences {
channel?: ElectronUpdaterChannel
}
export const electronGetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterPreferences>('eventa:invoke:electron:auto-updater:get-preferences')
export const electronSetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterPreferences, ElectronUpdaterPreferences>('eventa:invoke:electron:auto-updater:set-preferences')
export const electronPluginList = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:list')
export const electronPluginSetEnabled = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean, path?: string }>('eventa:invoke:electron:plugins:set-enabled')
export const electronPluginLoadEnabled = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:load-enabled')