diff --git a/.github/scripts/plan-tamagotchi-updates.test.ts b/.github/scripts/plan-tamagotchi-updates.test.ts index 9e1ffd09cf..b35482f971 100644 --- a/.github/scripts/plan-tamagotchi-updates.test.ts +++ b/.github/scripts/plan-tamagotchi-updates.test.ts @@ -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() diff --git a/.github/scripts/plan-tamagotchi-updates.ts b/.github/scripts/plan-tamagotchi-updates.ts index 947e7167b8..655cff986d 100644 --- a/.github/scripts/plan-tamagotchi-updates.ts +++ b/.github/scripts/plan-tamagotchi-updates.ts @@ -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() { diff --git a/apps/stage-tamagotchi/ergo.config.ts b/apps/stage-tamagotchi/ergo.config.ts index eef5327849..0b29aa34c2 100644 --- a/apps/stage-tamagotchi/ergo.config.ts +++ b/apps/stage-tamagotchi/ergo.config.ts @@ -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. diff --git a/apps/stage-tamagotchi/src/main/configs/global.test.ts b/apps/stage-tamagotchi/src/main/configs/global.test.ts new file mode 100644 index 0000000000..69c458f95c --- /dev/null +++ b/apps/stage-tamagotchi/src/main/configs/global.test.ts @@ -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) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/configs/global.ts b/apps/stage-tamagotchi/src/main/configs/global.ts index 42a79eb06f..03d52c9e1c 100644 --- a/apps/stage-tamagotchi/src/main/configs/global.ts +++ b/apps/stage-tamagotchi/src/main/configs/global.ts @@ -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 + +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() { diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts index 8ef595d08b..c762b2da68 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts @@ -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 () => { diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts index 17dfc10ae7..88d26e9669 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts @@ -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 | 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')) }, diff --git a/apps/stage-tamagotchi/src/renderer/pages/about.vue b/apps/stage-tamagotchi/src/renderer/pages/about.vue index 96888b6686..0719ecfd57 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/about.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/about.vue @@ -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('auto') diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue index 47f3fd350e..235aa396d2 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue @@ -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) diff --git a/apps/stage-tamagotchi/src/shared/eventa/index.ts b/apps/stage-tamagotchi/src/shared/eventa/index.ts index 0f9d39e3de..999625a1ff 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/index.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/index.ts @@ -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>('eventa:invoke:electron:server-channel:apply-config') export const electronGetServerChannelQrPayload = defineInvokeEventa('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 diff --git a/apps/stage-tamagotchi/src/shared/update-channels.test.ts b/apps/stage-tamagotchi/src/shared/update-channels.test.ts new file mode 100644 index 0000000000..f301c79c38 --- /dev/null +++ b/apps/stage-tamagotchi/src/shared/update-channels.test.ts @@ -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) + }) +}) diff --git a/apps/stage-tamagotchi/src/shared/update-channels.ts b/apps/stage-tamagotchi/src/shared/update-channels.ts new file mode 100644 index 0000000000..0cdf2a3c99 --- /dev/null +++ b/apps/stage-tamagotchi/src/shared/update-channels.ts @@ -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' +} diff --git a/packages/i18n/src/locales/en/tamagotchi/stage.yaml b/packages/i18n/src/locales/en/tamagotchi/stage.yaml index ec910ee234..2bc6d0eb0f 100644 --- a/packages/i18n/src/locales/en/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/en/tamagotchi/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml index c3f937b2ca..b083acee2e 100644 --- a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml @@ -120,8 +120,9 @@ about: stable: 稳定版 alpha: Alpha 测试版 beta: Beta 测试版 - nightly: 每夜构建 canary: 尝鲜版 + nightly: 每日更新版 + dev: 开发版 status: downloading: 正在下载更新...... error-prefix: 错误