diff --git a/apps/desktop/src/services/AppUpdateService/index.ts b/apps/desktop/src/services/AppUpdateService/index.ts index 9a583aad..9d2248df 100644 --- a/apps/desktop/src/services/AppUpdateService/index.ts +++ b/apps/desktop/src/services/AppUpdateService/index.ts @@ -56,6 +56,8 @@ export class AppUpdateController { private readonly now: () => string; private initialized = false; private unlistenProgress: UnlistenFn | null = null; + private checkRequestVersion = 0; + private channelTransitionQueue: Promise = Promise.resolve(); constructor(deps: AppUpdateControllerDeps) { this.native = deps.native; @@ -109,11 +111,52 @@ export class AppUpdateController { } } - async setChannel(channel: AppUpdateChannel): Promise { - await this.initialize(); - await this.settings.updateAppUpdateChannel(channel); - await this.settings.updateAppUpdateLastCheckedAt(null); - this.commit({ type: 'channel-updated', channel }); + setChannel(channel: AppUpdateChannel): Promise { + if (this.initialized) { + return this.queueChannelTransition(channel); + } + + return this.initialize().then(() => this.queueChannelTransition(channel)); + } + + private queueChannelTransition(channel: AppUpdateChannel): Promise { + this.invalidateChecksForChannelTransition(); + const transition = this.channelTransitionQueue.then(() => + this.runChannelTransition(channel) + ); + this.channelTransitionQueue = transition.catch(() => undefined); + return transition; + } + + private async runChannelTransition(channel: AppUpdateChannel): Promise { + const restoreState = this.invalidateChecksForChannelTransition(); + const previousChannel = restoreState.channel; + + try { + await this.settings.updateAppUpdateChannel(channel); + await this.settings.updateAppUpdateLastCheckedAt(null); + this.commit({ type: 'channel-updated', channel }); + } catch (error) { + await this.settings.updateAppUpdateChannel(previousChannel).catch(() => undefined); + const persistedChannel = this.settings.getChannel(); + this.replaceState( + persistedChannel === previousChannel + ? restoreState + : reduceAppUpdateState(restoreState, { + type: 'channel-updated', + channel: persistedChannel, + }) + ); + throw error; + } + } + + private invalidateChecksForChannelTransition(): AppUpdateState { + const restoreState: AppUpdateState = + this.state.status === 'checking' ? { ...this.state, status: 'idle' } : this.state; + this.checkRequestVersion += 1; + this.replaceState(restoreState); + return restoreState; } async checkNow(source: AppUpdateCheckSource = 'manual'): Promise { @@ -125,15 +168,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 +223,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..e0a7aee3 100644 --- a/apps/desktop/tests/services/AppUpdateService/service.test.ts +++ b/apps/desktop/tests/services/AppUpdateService/service.test.ts @@ -48,6 +48,8 @@ function createController( lastCheckedAt?: string | null; checkResult?: AppUpdateCheckResult; checkError?: Error; + updateChannelError?: Error; + updateLastCheckedAtError?: Error; } = {} ) { const checkForUpdates = options.checkError @@ -66,9 +68,19 @@ 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); + const updateAppUpdateLastCheckedAt = options.updateLastCheckedAtError + ? vi + .fn() + .mockImplementation((checkedAt: string | null) => + checkedAt === null + ? Promise.reject(options.updateLastCheckedAtError) + : Promise.resolve(undefined) + ) + : vi.fn().mockResolvedValue(undefined); const controller = new AppUpdateController({ native: { @@ -170,6 +182,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 +287,295 @@ 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('invalidates in-flight checks before channel persistence completes', async () => { + const deferredCheck = createDeferred(); + const deferredChannelUpdate = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateChannel } = createController(); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + updateAppUpdateChannel.mockReturnValueOnce(deferredChannelUpdate.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + const setChannelPromise = controller.setChannel('nightly'); + await Promise.resolve(); + + deferredCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + const checked = await checkPromise; + + deferredChannelUpdate.resolve(); + await setChannelPromise; + + expect(checked).toBe(false); + }); + + it('invalidates a resolved check synchronously when changing channels', async () => { + const deferredCheck = createDeferred(); + const { controller, checkForUpdates } = createController(); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + + deferredCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + const setChannelPromise = controller.setChannel('nightly'); + + await expect(checkPromise).resolves.toBe(false); + await setChannelPromise; + }); + + it('restores the previous state 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, + }); + + expect(controller.getState().status).not.toBe('checking'); + expect(controller.getState().channel).toBe('stable'); + await expect(checkPromise).resolves.toBe(false); + expect(updateAppUpdateLastCheckedAt).not.toHaveBeenCalled(); + }); + + it('initializes before snapshotting a channel transition', async () => { + const operationError = new Error('database unavailable'); + const { controller } = createController({ + channel: 'beta', + updateChannelError: operationError, + }); + + await expect(controller.setChannel('nightly')).rejects.toBe(operationError); + + expect(controller.getState()).toMatchObject({ + status: 'idle', + channel: 'beta', + }); + }); + + it('rolls back the persisted channel when clearing the channel timestamp fails', async () => { + const deferredCheck = createDeferred(); + const { + controller, + checkForUpdates, + updateAppUpdateChannel, + updateAppUpdateLastCheckedAt, + } = createController({ + updateLastCheckedAtError: new Error('timestamp unavailable'), + }); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + + await expect(controller.setChannel('nightly')).rejects.toThrow('timestamp unavailable'); + deferredCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + + expect(controller.getState().status).not.toBe('checking'); + expect(controller.getState().channel).toBe('stable'); + await expect(checkPromise).resolves.toBe(false); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(1, 'nightly'); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(2, 'stable'); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledOnce(); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith(null); + }); + + it('reconciles state when a channel write rejects after mutation and compensation fails', async () => { + let persistedChannel: AppUpdateChannel = 'stable'; + const operationError = new Error('settings broadcast failed'); + const compensationError = new Error('compensation failed'); + const updateAppUpdateChannel = vi + .fn<(channel: AppUpdateChannel) => Promise>() + .mockImplementation(async (channel) => { + if (channel === 'nightly') { + persistedChannel = channel; + throw operationError; + } + throw compensationError; + }); + const controller = new AppUpdateController({ + native: { + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + installUpdate: vi.fn(), + }, + settings: { + initialize: vi.fn().mockResolvedValue(undefined), + getChannel: () => persistedChannel, + getAutoCheckEnabled: () => true, + getLastCheckedAt: () => null, + updateAppUpdateChannel, + updateAppUpdateAutoCheck: vi.fn().mockResolvedValue(undefined), + updateAppUpdateLastCheckedAt: vi.fn().mockResolvedValue(undefined), + }, + }); + + await controller.initialize(); + await expect(controller.setChannel('nightly')).rejects.toBe(operationError); + + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(1, 'nightly'); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(2, 'stable'); + expect(persistedChannel).toBe('nightly'); + expect(controller.getState()).toMatchObject({ + status: 'idle', + channel: persistedChannel, + }); + }); + + it('does not let an older failed channel transition undo a newer selection', async () => { + let persistedChannel: AppUpdateChannel = 'stable'; + const olderUpdate = createDeferred(); + const olderError = new Error('older transition failed'); + const updateAppUpdateChannel = vi + .fn<(channel: AppUpdateChannel) => Promise>() + .mockImplementation(async (channel) => { + if (channel === 'beta') { + await olderUpdate.promise; + } + persistedChannel = channel; + }); + const controller = new AppUpdateController({ + native: { + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + installUpdate: vi.fn(), + }, + settings: { + initialize: vi.fn().mockResolvedValue(undefined), + getChannel: () => persistedChannel, + getAutoCheckEnabled: () => true, + getLastCheckedAt: () => null, + updateAppUpdateChannel, + updateAppUpdateAutoCheck: vi.fn().mockResolvedValue(undefined), + updateAppUpdateLastCheckedAt: vi.fn().mockResolvedValue(undefined), + }, + }); + + await controller.initialize(); + const olderTransition = controller.setChannel('beta'); + await Promise.resolve(); + const newerTransition = controller.setChannel('nightly'); + + olderUpdate.reject(olderError); + + await expect(olderTransition).rejects.toBe(olderError); + await expect(newerTransition).resolves.toBeUndefined(); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(1, 'beta'); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(2, 'stable'); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(3, 'nightly'); + expect(persistedChannel).toBe('nightly'); + expect(controller.getState().channel).toBe('nightly'); + }); + + 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();