diff --git a/apps/stage-tamagotchi/src/main/configs/global.ts b/apps/stage-tamagotchi/src/main/configs/global.ts index 5ba7f5613..6c8973db9 100644 --- a/apps/stage-tamagotchi/src/main/configs/global.ts +++ b/apps/stage-tamagotchi/src/main/configs/global.ts @@ -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() { diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 930f612e0..b7998c9d2 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -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 }, 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 ae3365ddd..0e5f5209e 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 @@ -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 }) }) }) 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 7cd3e6495..66fb2f42b 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts @@ -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 @@ -62,23 +135,37 @@ export interface AutoUpdater { checkForUpdates: () => Promise downloadUpdate: () => Promise quitAndInstall: () => Promise + getPreferredUpdateLane: () => UpdateLane | undefined + setPreferredUpdateLane: (lane: UpdateLane | undefined) => Promise 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 | 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() }), diff --git a/apps/stage-tamagotchi/src/renderer/pages/about.vue b/apps/stage-tamagotchi/src/renderer/pages/about.vue index 13a16d7f3..2c59fb09b 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/about.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/about.vue @@ -1,13 +1,17 @@