Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/desktop/src/services/AppUpdateService/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -113,6 +114,7 @@ export class AppUpdateController {
await this.initialize();
await this.settings.updateAppUpdateChannel(channel);
await this.settings.updateAppUpdateLastCheckedAt(null);
this.checkRequestVersion += 1;
this.commit({ type: 'channel-updated', channel });
}

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
186 changes: 184 additions & 2 deletions apps/desktop/tests/services/AppUpdateService/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ function createController(
lastCheckedAt?: string | null;
checkResult?: AppUpdateCheckResult;
checkError?: Error;
updateChannelError?: Error;
updateLastCheckedAtError?: Error;
} = {}
) {
const checkForUpdates = options.checkError
Expand All @@ -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: {
Expand Down Expand Up @@ -170,6 +182,33 @@ describe('AppUpdateController', () => {
});
});

it('does not restore an old channel after a stale automatic check fails', async () => {
const deferredCheck = createDeferred<AppUpdateCheckResult>();
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'),
Expand Down Expand Up @@ -248,6 +287,149 @@ describe('AppUpdateController', () => {
});
});

it('does not persist stale check timestamps after the user switches channels', async () => {
const deferredCheck = createDeferred<AppUpdateCheckResult>();
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<AppUpdateCheckResult>();
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('keeps in-flight checks current when clearing the channel timestamp fails', async () => {
const deferredCheck = createDeferred<AppUpdateCheckResult>();
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,
});

await expect(checkPromise).resolves.toBe(true);
expect(controller.getState()).toMatchObject({
status: 'available',
channel: 'stable',
availableUpdate,
lastCheckedAt: '2026-05-22T10:00:00.000Z',
});
expect(updateAppUpdateChannel).toHaveBeenCalledWith('nightly');
expect(updateAppUpdateLastCheckedAt).toHaveBeenNthCalledWith(1, null);
expect(updateAppUpdateLastCheckedAt).toHaveBeenNthCalledWith(2, '2026-05-22T10:00:00.000Z');
});

it('ignores an older check result when a newer same-channel check finishes first', async () => {
const firstCheck = createDeferred<AppUpdateCheckResult>();
const secondCheck = createDeferred<AppUpdateCheckResult>();
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();

Expand Down
Loading