diff --git a/apps/desktop/src/services/AppUpdateService/index.ts b/apps/desktop/src/services/AppUpdateService/index.ts index 9a583aad..62206d7a 100644 --- a/apps/desktop/src/services/AppUpdateService/index.ts +++ b/apps/desktop/src/services/AppUpdateService/index.ts @@ -56,6 +56,7 @@ export class AppUpdateController { private readonly now: () => string; private initialized = false; private unlistenProgress: UnlistenFn | null = null; + private checkRequestVersion = 0; constructor(deps: AppUpdateControllerDeps) { this.native = deps.native; @@ -112,6 +113,7 @@ export class AppUpdateController { async setChannel(channel: AppUpdateChannel): Promise { await this.initialize(); await this.settings.updateAppUpdateChannel(channel); + this.checkRequestVersion += 1; await this.settings.updateAppUpdateLastCheckedAt(null); this.commit({ type: 'channel-updated', channel }); } @@ -125,15 +127,24 @@ export class AppUpdateController { const previousState = this.state; const channel = this.state.channel; + const requestVersion = ++this.checkRequestVersion; this.commit({ type: 'check-started', channel }); try { const result = await this.native.checkForUpdates(channel); const checkedAt = this.now(); + if (!this.isCurrentCheckRequest(requestVersion, channel)) { + return false; + } + this.commit({ type: 'check-completed', channel, result, checkedAt }); await this.settings.updateAppUpdateLastCheckedAt(checkedAt); return true; } catch (error) { + if (!this.isCurrentCheckRequest(requestVersion, channel)) { + return false; + } + if (source === 'automatic') { this.replaceState(previousState); return false; @@ -171,6 +182,10 @@ export class AppUpdateController { } } + private isCurrentCheckRequest(requestVersion: number, channel: AppUpdateChannel): boolean { + return requestVersion === this.checkRequestVersion && this.state.channel === channel; + } + private shouldRunAutomaticCheck(): boolean { if (!this.state.autoCheckEnabled) { return false; diff --git a/apps/desktop/tests/services/AppUpdateService/service.test.ts b/apps/desktop/tests/services/AppUpdateService/service.test.ts index bee64628..97f6e514 100644 --- a/apps/desktop/tests/services/AppUpdateService/service.test.ts +++ b/apps/desktop/tests/services/AppUpdateService/service.test.ts @@ -48,6 +48,7 @@ function createController( lastCheckedAt?: string | null; checkResult?: AppUpdateCheckResult; checkError?: Error; + updateChannelError?: Error; } = {} ) { const checkForUpdates = options.checkError @@ -66,7 +67,9 @@ function createController( ); const downloadUpdate = vi.fn().mockResolvedValue(availableUpdate); const installUpdate = vi.fn().mockResolvedValue(true); - const updateAppUpdateChannel = vi.fn().mockResolvedValue(undefined); + const updateAppUpdateChannel = options.updateChannelError + ? vi.fn().mockRejectedValue(options.updateChannelError) + : vi.fn().mockResolvedValue(undefined); const updateAppUpdateAutoCheck = vi.fn().mockResolvedValue(undefined); const updateAppUpdateLastCheckedAt = vi.fn().mockResolvedValue(undefined); @@ -170,6 +173,33 @@ describe('AppUpdateController', () => { }); }); + it('does not restore an old channel after a stale automatic check fails', async () => { + const deferredCheck = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController(); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('automatic'); + await Promise.resolve(); + + expect(controller.getState()).toMatchObject({ + status: 'checking', + channel: 'stable', + }); + + await controller.setChannel('nightly'); + deferredCheck.reject(new Error('network unavailable')); + + await expect(checkPromise).resolves.toBe(false); + expect(controller.getState()).toMatchObject({ + status: 'idle', + channel: 'nightly', + error: null, + }); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledTimes(1); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith(null); + }); + it('surfaces manual check failures', async () => { const { controller } = createController({ checkError: new Error('network unavailable'), @@ -248,6 +278,111 @@ describe('AppUpdateController', () => { }); }); + it('does not persist stale check timestamps after the user switches channels', async () => { + const deferredCheck = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController(); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + + await controller.setChannel('nightly'); + deferredCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + + await expect(checkPromise).resolves.toBe(false); + expect(controller.getState()).toMatchObject({ + status: 'idle', + channel: 'nightly', + availableUpdate: null, + lastCheckedAt: null, + }); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledTimes(1); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith(null); + }); + + it('keeps in-flight checks current when channel persistence fails', async () => { + const deferredCheck = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController({ + updateChannelError: new Error('database unavailable'), + }); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + + await expect(controller.setChannel('nightly')).rejects.toThrow('database unavailable'); + deferredCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + + await expect(checkPromise).resolves.toBe(true); + expect(controller.getState()).toMatchObject({ + status: 'available', + channel: 'stable', + availableUpdate, + lastCheckedAt: '2026-05-22T10:00:00.000Z', + }); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledTimes(1); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith('2026-05-22T10:00:00.000Z'); + }); + + it('ignores an older check result when a newer same-channel check finishes first', async () => { + const firstCheck = createDeferred(); + const secondCheck = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController(); + checkForUpdates + .mockReturnValueOnce(firstCheck.promise) + .mockReturnValueOnce(secondCheck.promise); + + await controller.initialize(); + const firstPromise = controller.checkNow('manual'); + await Promise.resolve(); + const secondPromise = controller.checkNow('manual'); + await Promise.resolve(); + + secondCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + await expect(secondPromise).resolves.toBe(true); + + firstCheck.resolve({ + status: 'not_available', + channel: 'stable', + currentVersion: '0.2.0', + latest: latestUpdate, + requirement: neutralRequirement, + }); + await expect(firstPromise).resolves.toBe(false); + + expect(controller.getState()).toMatchObject({ + status: 'available', + channel: 'stable', + availableUpdate, + currentVersion: '0.1.0', + }); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledTimes(1); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith('2026-05-22T10:00:00.000Z'); + }); + it('persists auto-check changes', async () => { const { controller, updateAppUpdateAutoCheck } = createController();