fix(stage-tamagotchi): keep four update channels and report health after the stage loads (#3165)

This commit is contained in:
Neko
2026-10-28 00:27:25 +00:00
committed by GitHub
parent 76906c57f4
commit dfd9453bea
14 changed files with 168 additions and 81 deletions
@@ -22,16 +22,17 @@ describe('planUpdates', () => {
{ tag: 'v0.13.0', channel: 'stable' },
{ tag: '0.13.0', channel: 'stable' },
{ tag: 'v0.13.0+build.7', channel: 'stable' },
{ tag: 'v0.13.0-beta.1', channel: 'beta' },
{ tag: 'v0.13.0-alpha.36', channel: 'alpha' },
{ tag: 'v0.13.0-Nightly.20261026', channel: 'nightly' },
{ tag: 'v0.13.0-beta.1', channel: 'canary' },
{ tag: 'v0.13.0-alpha.36', channel: 'canary' },
{ tag: 'v0.13.0-rc.1', channel: 'canary' },
{ tag: 'v0.13.0-1', channel: 'canary' },
{ tag: 'v0.13.0-canary1', channel: 'canary' },
])('publishes the release $tag to $channel with the version of package.json', ({ tag, channel }) => {
expect(planUpdates({ ...base, event: 'release', tag })).toEqual({ ...planUpdates({ ...base, event: 'push' }), channel, version: '0.13.0' })
})
// A prerelease that names no channel must never fall back to stable, or stable installs would take it.
it.each(['v0.13.0-rc.1', 'v0.13.0-1', 'v0.13.0.1', 'release-2026', 'v0.13'])('publishes nothing for the tag %s', (tag) => {
// A tag that is not a version must never fall back to stable, or stable installs would take it.
it.each(['v0.13.0.1', 'release-2026', 'v0.13'])('publishes nothing for the tag %s', (tag) => {
const plan = planUpdates({ ...base, event: 'release', tag })
expect(plan.channel).toBeUndefined()
@@ -39,7 +40,7 @@ describe('planUpdates', () => {
})
it('publishes a manual release run with a tag, and nothing for other manual runs', () => {
expect(planUpdates({ ...base, event: 'workflow_dispatch', tag: 'v0.13.0-beta.2' }).channel).toBe('beta')
expect(planUpdates({ ...base, event: 'workflow_dispatch', tag: 'v0.13.0-beta.2' }).channel).toBe('canary')
expect(planUpdates({ ...base, event: 'workflow_dispatch' })).toMatchObject({ skipReason: 'This run is not a release.' })
expect(planUpdates({ ...base, event: 'workflow_dispatch' }).channel).toBeUndefined()
expect(planUpdates({ ...base, event: 'workflow_dispatch', tag: 'v0.13.0', buildOnly: true }).channel).toBeUndefined()
+4 -15
View File
@@ -3,9 +3,6 @@ import process from 'node:process'
import { appendFile, readFile } from 'node:fs/promises'
import { pathToFileURL } from 'node:url'
/** The update channels that a release tag can name with its prerelease, as `ergo.config.ts` derives them. */
const PRERELEASE_CHANNELS = ['alpha', 'beta', 'nightly', 'canary'] as const
export interface PlanInput {
/** `github.event_name`. */
event: string
@@ -40,9 +37,9 @@ export interface UpdatePlan {
*
* - A push to main publishes to `dev`, and a scheduled run to `nightly`. Their versions name the build, for example
* `0.12.0+dev.45.1bf946f`. Semver ignores the part after `+`, so the about page does not take them for a downgrade.
* - A release, or a manual release run with a tag, publishes to the lane of the tag: `v0.13.0-beta.1` to `beta`, and
* `v0.13.0` to `stable`. A prerelease of another name, for example `v0.13.0-rc.1`, publishes nothing, so it never
* reaches stable.
* - A release, or a manual release run with a tag, publishes a prerelease such as `v0.13.0-rc.1` or `v0.13.0-beta.1`
* to `canary`, and `v0.13.0` to `stable`. `channelOfVersion` of `apps/stage-tamagotchi/src/shared/update-channels.ts`
* gives the same channel for the version, so an install follows the channel that gets its next release.
* - Other manual runs publish nothing.
*/
export function planUpdates(input: PlanInput): UpdatePlan {
@@ -66,15 +63,7 @@ export function planUpdates(input: PlanInput): UpdatePlan {
const match = /^v?\d+\.\d+\.\d+(?:-([0-9a-z.-]+))?(?:\+[0-9a-z.-]+)?$/i.exec(input.tag)
if (!match)
return { ...ids, skipReason: `The tag ${input.tag} is not a version.`, version }
if (match[1] === undefined)
return { ...ids, channel: 'stable', version }
// The name of the prerelease: `beta` of `beta.1`, and of `beta1`.
const prerelease = /^[a-z]+/i.exec(match[1])?.[0].toLowerCase()
if (prerelease !== undefined && (PRERELEASE_CHANNELS as readonly string[]).includes(prerelease))
return { ...ids, channel: prerelease, version }
return { ...ids, skipReason: `The tag ${input.tag} names no update channel.`, version }
return { ...ids, channel: match[1] === undefined ? 'stable' : 'canary', version }
}
async function main() {
+8 -10
View File
@@ -3,19 +3,17 @@ import { env } from 'node:process'
import { defineConfig } from '@ergosphere-it/electron/config'
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { version: string }
// Node loads this file with type stripping, which resolves only full file names.
// eslint-disable-next-line no-restricted-syntax
import { channelOfVersion } from './src/shared/update-channels.ts'
/**
* The channel of a build: the prerelease name of the version, for example `beta` for `0.9.0-beta.4`, or `stable`.
* The app can switch to another lane at run time, see `src/main/services/electron/auto-updater.ts`.
*/
function laneOf(version: string) {
const prerelease = /^\d+\.\d+\.\d+-([a-z]+)/i.exec(version)?.[1]?.toLowerCase()
return prerelease && ['alpha', 'beta', 'nightly', 'canary'].includes(prerelease) ? prerelease : 'stable'
}
// CI names each build in `ERGOSPHERE_IT_VERSION`, for example `0.13.0+nightly.12.13eef81`. The build uses the same name.
const version = env.ERGOSPHERE_IT_VERSION
|| (JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { version: string }).version
export default defineConfig({
channel: env.AIRI_UPDATE_CHANNEL || laneOf(version),
// The app can switch to another channel at run time, see `src/main/services/electron/auto-updater.ts`.
channel: env.AIRI_UPDATE_CHANNEL || channelOfVersion(version),
// Steam updates the Steam build. Flatpak and Nix are known only at runtime, see src/launcher/index.ts.
disabled: env.VITE_DISTRIBUTION === 'steam',
// A test build can point at another feed and key, for example a local S3.
@@ -0,0 +1,16 @@
import { parse } from 'valibot'
import { describe, expect, it } from 'vitest'
import { globalAppConfigSchema } from './global'
describe('globalAppConfigSchema', () => {
it.each([
{ stored: 'stable', expected: 'stable' },
{ stored: 'dev', expected: 'dev' },
{ stored: 'latest', expected: undefined },
{ stored: 'alpha', expected: 'canary' },
{ stored: 'beta', expected: 'canary' },
])('reads the stored update channel $stored as $expected', ({ stored, expected }) => {
expect(parse(globalAppConfigSchema, { updateChannel: stored }).updateChannel).toBe(expected)
})
})
@@ -1,4 +1,6 @@
import { array, boolean, object, optional, picklist, string } from 'valibot'
import type { UpdateChannel } from '../../shared/update-channels'
import { array, boolean, object, optional, picklist, pipe, string, transform } from 'valibot'
import { createConfig } from '../libs/electron/persistence'
@@ -7,11 +9,30 @@ const shortcutAcceleratorSchema = object({
key: string(),
})
// NOTICE:
// Releases up to v0.13.0 let the user choose `latest`, `alpha`, and `beta`, which the store never had.
// `latest` becomes auto. `alpha` and `beta` become `canary`, which gets every prerelease.
// Remove the old names after the next stable release.
const storedUpdateChannels = {
stable: 'stable',
canary: 'canary',
nightly: 'nightly',
dev: 'dev',
latest: undefined,
alpha: 'canary',
beta: 'canary',
} as const satisfies Record<UpdateChannel | 'latest' | 'alpha' | 'beta', UpdateChannel | undefined>
const updateChannelSchema = pipe(
picklist(['stable', 'canary', 'nightly', 'dev', 'latest', 'alpha', 'beta']),
transform(channel => storedUpdateChannels[channel]),
)
export const globalAppConfigSchema = object({
hideAppIcon: optional(boolean()),
language: optional(string()),
spotlightShortcutAccelerator: optional(shortcutAcceleratorSchema),
updateChannel: optional(picklist(['latest', 'stable', 'alpha', 'beta', 'nightly', 'canary', 'dev'])),
updateChannel: optional(updateChannelSchema),
})
export function createGlobalAppConfig() {
@@ -133,7 +133,7 @@ describe('setupAutoUpdater', () => {
it.each([
{ name: 'the lane that the user chose', stored: 'nightly', env: undefined, expected: 'nightly' },
{ name: 'AIRI_UPDATE_CHANNEL', stored: 'nightly', env: 'alpha', expected: 'alpha' },
{ name: 'AIRI_UPDATE_CHANNEL', stored: 'nightly', env: 'dev', expected: 'dev' },
] as const)('follows $name', async ({ stored, env, expected }) => {
if (env)
vi.stubEnv('AIRI_UPDATE_CHANNEL', env)
@@ -159,6 +159,20 @@ describe('setupAutoUpdater', () => {
expect(fake.updater.channel).toBe('ota-test')
})
it.each([
{ saved: 'latest', version: '0.13.0+dev.821.13eef81', expected: 'dev' },
{ saved: 'beta', version: '0.14.0-rc.1', expected: 'canary' },
])('leaves the removed channel $saved that the launcher saved, for the channel of $version', async ({ saved, version, expected }) => {
bundleState.version = version
const fake = createFakeUpdater()
fake.updater.channel = saved
const { setupAutoUpdater } = await import('./auto-updater')
setupAutoUpdater({ updater: fake.updater })
expect(fake.updater.channel).toBe(expected)
})
it('switches the channel, saves the choice, and checks again when the user picks a lane', async () => {
const fake = createFakeUpdater()
const setStoredUpdateLane = vi.fn()
@@ -173,14 +187,21 @@ describe('setupAutoUpdater', () => {
expect(service.getPreferredUpdateLane()).toBe('nightly')
})
it('goes back to the lane of the build when the user picks auto', async () => {
it.each([
{ version: '0.9.0-beta.4', expected: 'canary' },
{ version: '0.13.0+nightly.12.13eef81', expected: 'nightly' },
{ version: '0.13.0+dev.821.13eef81', expected: 'dev' },
{ version: '0.13.0', expected: 'stable' },
])('goes back to the channel of $version when the user picks auto', async ({ version, expected }) => {
bundleState.version = version
const fake = createFakeUpdater()
fake.updater.channel = 'canary'
const { setupAutoUpdater } = await import('./auto-updater')
const service = setupAutoUpdater({ updater: fake.updater, getStoredUpdateLane: () => 'nightly' })
const service = setupAutoUpdater({ updater: fake.updater, getStoredUpdateLane: () => 'dev' })
await service.setPreferredUpdateLane(undefined)
expect(fake.updater.channel).toBe('beta')
expect(fake.updater.channel).toBe(expected)
})
it('applies a ready update once while two clicks arrive at once', async () => {
@@ -10,8 +10,6 @@ import process from 'node:process'
import { appendFile, mkdir } from 'node:fs/promises'
import { dirname, join, normalize } from 'node:path'
import semver from 'semver'
import { activeBundle, updater as ergosphereUpdater } from '@ergosphere-it/electron/main'
import { useLogg } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
@@ -26,6 +24,7 @@ import {
electronGetUpdaterPreferences,
electronSetUpdaterPreferences,
} from '../../../shared/eventa'
import { channelOfVersion, updateChannels } from '../../../shared/update-channels'
const UPDATE_CHANNEL_ENV_KEY = 'AIRI_UPDATE_CHANNEL'
@@ -48,35 +47,15 @@ async function logToFile(level: 'INFO' | 'WARN' | 'ERROR', message: string) {
export type UpdateLane = ElectronUpdaterChannel
function normalizeLane(value: string | undefined): UpdateLane | undefined {
if (!value)
return undefined
switch (value.toLowerCase()) {
case 'stable':
case 'latest':
case 'alpha':
case 'beta':
case 'nightly':
case 'canary':
case 'dev':
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'
const lane = value?.toLowerCase()
return updateChannels.find(channel => channel === lane)
}
/**
* The lane that the user or the environment chose: `AIRI_UPDATE_CHANNEL`, then the channel that the user chose.
* Without one, the app follows the channel of its build, which `ergo.config.ts` derives from the version, so a beta
* build follows `beta`.
* Without one, the app follows the channel of its version, see `channelOfVersion`, so a nightly build follows `nightly`.
*
* Each lane is a channel of the ergosphere-it release store with the same name. `latest` is the channel that every
* release is promoted to. `dev` gets the bundle of each push to main, from `.github/workflows/release-tamagotchi.yml`.
* Each lane is a channel of the ergosphere-it release store with the same name.
*/
export function getChosenUpdateLane(storedLane: UpdateLane | undefined): UpdateLane | undefined {
return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? storedLane
@@ -304,9 +283,16 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
log.withError(error).error(reason)
}
const chosenLane = getChosenUpdateLane(storedPreferredLane)
if (updater.status.status !== 'disabled' && chosenLane)
follow(chosenLane)
if (updater.status.status !== 'disabled') {
// NOTICE:
// Up to v0.13.0, the launcher saved `latest`, `alpha`, or `beta`, which the store never had. Each check got a 404.
// `src/main/configs/global.ts` maps the choice of the user. This maps the channel that the launcher saved.
// Remove it after the next stable release, with the mapping in `global.ts`.
const savedRemovedChannel = ['latest', 'alpha', 'beta'].includes(updater.channel)
const lane = getChosenUpdateLane(storedPreferredLane) ?? (savedRemovedChannel ? channelOfVersion(activeBundle.version) : undefined)
if (lane)
follow(lane)
}
updater.onEvent((event: LauncherEvent) => {
// A download reports progress many times a second. Its start and its end are statuses of their own.
@@ -334,8 +320,8 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
let applying: Promise<void> | undefined
// The launcher keeps a new bundle only when it reports healthy, and rolls it back otherwise. The bundle works when
// main started every module and the main window mounted its page. A page-only update reloads the windows in place,
// so its page reports again after the reload.
// main started every module and the main window finished loading its stage. After a page-only update, the reloaded
// page reports again.
let mainReady = false
let pageMounted = false
function reportHealthWhenReady() {
@@ -384,8 +370,7 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
storedPreferredLane = lane
options.setStoredUpdateLane?.(lane)
// Auto goes back to the lane of the running version, as `ergo.config.ts` derives the channel of a build.
const next = getChosenUpdateLane(lane) ?? laneFromVersion(activeBundle.version)
const next = getChosenUpdateLane(lane) ?? channelOfVersion(activeBundle.version)
if (updater.status.status !== 'disabled' && follow(next))
await updater.checkForUpdates().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
},
@@ -16,6 +16,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { electronAppGetVersion, electronGetUpdaterPreferences, electronSetUpdaterPreferences } from '../../shared/eventa'
import { updateChannels } from '../../shared/update-channels'
const buildInfo = useBuildInfo()
// The main process owns the app version. Packaging can change it after the renderer build, so `buildInfo.version`
@@ -64,12 +65,10 @@ const updaterErrorMessage = computed(() => {
const showChangelog = ref(false)
const showBugReportDialog = ref(false)
const { isDesktop } = useBreakpoints()
const updateChannelOptions = ['auto', 'latest', 'stable', 'alpha', 'beta', 'nightly', 'canary', 'dev'] as const
const updateChannelOptions = ['auto', ...updateChannels] as const
type UpdateChannelOption = typeof updateChannelOptions[number]
const updateChannelSelectOptions = computed(() => updateChannelOptions.map(channel => ({
label: channel === 'latest'
? 'Latest (any release)'
: t(`tamagotchi.stage.about.update.channels.${channel}`),
label: t(`tamagotchi.stage.about.update.channels.${channel}`),
value: channel,
})))
const selectedUpdateChannel = ref<UpdateChannelOption>('auto')
@@ -26,7 +26,7 @@ import { useFeatureFlagsStore } from '@proj-airi/stage-ui/stores/feature-flags'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { useVoiceStore } from '@proj-airi/stage-ui/stores/voice'
import { refDebounced } from '@vueuse/core'
import { refDebounced, until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onMounted, onScopeDispose, ref, shallowRef, toRef, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -199,9 +199,10 @@ useElectronEventaInvoke(electronAppIsWayland)()
.then(value => isWayland.value = value)
.catch(error => console.warn('[Main Page] Failed to detect Wayland; the controls Island stays shown:', errorMessageFrom(error)))
// The update launcher keeps a new bundle only when it reports healthy. The main window mounting this page is part of it.
// The update launcher keeps a new bundle only when it reports healthy, and rolls it back otherwise. This page reports
// when the stage stops loading, with the model or with the error surface. A page stuck on loading never reports.
const reportPageMounted = useElectronEventaInvoke(electronMainPageMounted)
onMounted(() => void reportPageMounted())
until(isLoading).toBe(false).then(() => reportPageMounted())
const cursorAwayFromWindow = computed(() => !isWayland.value && isOutsideWindow.value && !isAroundWindowBorder.value)
@@ -31,6 +31,8 @@ import type {
import type { ChatHistoryReplyPayload } from '@proj-airi/stage-ui/components/scenarios/chat'
import type { Rectangle } from 'electron'
import type { UpdateChannel } from '../update-channels'
import { defineEventa, defineInvokeEventa } from '@moeru/eventa'
export const electronStartDraggingWindow = defineInvokeEventa('eventa:invoke:electron:start-dragging-window')
@@ -332,7 +334,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 = 'latest' | 'stable' | 'alpha' | 'beta' | 'nightly' | 'canary' | 'dev'
export type ElectronUpdaterChannel = UpdateChannel
export interface ElectronUpdaterPreferences {
channel?: ElectronUpdaterChannel
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { channelOfVersion } from './update-channels'
describe('channelOfVersion', () => {
it.each([
{ version: '0.13.0+dev.821.13eef81', expected: 'dev' },
{ version: '0.13.0+nightly.12.13eef81', expected: 'nightly' },
{ version: '0.14.0-rc.1+dev.3.13eef81', expected: 'dev' },
{ version: '0.14.0-rc.1', expected: 'canary' },
{ version: '0.14.0-beta.2', expected: 'canary' },
{ version: 'v0.14.0-alpha.1', expected: 'canary' },
{ version: '0.13.0', expected: 'stable' },
{ version: '0.13.0+build.7', expected: 'stable' },
])('maps $version to $expected', ({ version, expected }) => {
expect(channelOfVersion(version)).toBe(expected)
})
})
@@ -0,0 +1,35 @@
/**
* The update channels of the ergosphere-it release store, as `.github/scripts/plan-tamagotchi-updates.ts` publishes
* them.
*
* - `stable`: releases.
* - `canary`: prereleases, before they become releases.
* - `nightly`: the build of each day.
* - `dev`: the build of each push to main.
*/
export const updateChannels = ['stable', 'canary', 'nightly', 'dev'] as const
export type UpdateChannel = typeof updateChannels[number]
/**
* Returns the channel that a version comes from. With no channel that the user chose, the app follows this channel, and
* `ergo.config.ts` builds it in.
*
* Build metadata names a build of main, and a prerelease names a release candidate.
*
* @example
* channelOfVersion('0.13.0+dev.821.13eef81') // 'dev'
* channelOfVersion('0.13.0+nightly.12.13eef81') // 'nightly'
* channelOfVersion('0.14.0-rc.1') // 'canary'
* channelOfVersion('0.13.0') // 'stable'
*/
export function channelOfVersion(version: string): UpdateChannel {
const match = /^v?\d+\.\d+\.\d+(?:-([0-9a-z.-]+))?(?:\+([0-9a-z.-]+))?$/i.exec(version)
const build = match?.[2]?.split('.')[0]?.toLowerCase()
if (build === 'dev' || build === 'nightly')
return build
if (match?.[1] !== undefined)
return 'canary'
return 'stable'
}
@@ -127,9 +127,9 @@ about:
stable: Stable
alpha: Alpha
beta: Beta
nightly: Nightly
canary: Canary
dev: Dev (every change on main)
nightly: Nightly
dev: Dev
status:
downloading: Downloading update...
error-prefix: Error
@@ -120,8 +120,9 @@ about:
stable: 稳定版
alpha: Alpha 测试版
beta: Beta 测试版
nightly: 每夜构建
canary: 尝鲜版
nightly: 每日更新版
dev: 开发版
status:
downloading: 正在下载更新......
error-prefix: 错误