Skip to content
Merged
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
121 changes: 121 additions & 0 deletions src/main/updates/update-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { INSTALL_DOWNLOADED_UPDATE_CHANNEL } from '../../shared/ipc-channels';
import {
isInstallUpdateConfirmed,
registerUpdateIpc,
UPDATE_INSTALL_CONFIRMATION_OPTIONS,
type UpdateInstallConfirmation
} from './update-ipc';
import type { UpdateService } from './update-service';

type IpcHandler = (event: Electron.IpcMainInvokeEvent, ...args: unknown[]) => unknown;

const ipcHandlers = new Map<string, IpcHandler>();

vi.mock('electron', () => ({
BrowserWindow: {
fromWebContents: vi.fn()
},
dialog: {
showMessageBox: vi.fn()
},
ipcMain: {
handle: vi.fn((channel: string, handler: IpcHandler) => {
ipcHandlers.set(channel, handler);
})
}
}));

describe('registerUpdateIpc', () => {
beforeEach(() => {
ipcHandlers.clear();
});

it('installs a downloaded update after confirmation', async () => {
const updateService = createUpdateService({ downloaded: true, installResult: { ok: true } });
const confirmInstallDownloadedUpdate = vi.fn<UpdateInstallConfirmation>(async () => true);

registerUpdateIpc(updateService, { confirmInstallDownloadedUpdate });

await expect(invokeInstall()).resolves.toEqual({ ok: true });
expect(confirmInstallDownloadedUpdate).toHaveBeenCalledTimes(1);
expect(updateService.installDownloadedUpdate).toHaveBeenCalledTimes(1);
});

it('does not install a downloaded update when confirmation is cancelled', async () => {
const updateService = createUpdateService({ downloaded: true, installResult: { ok: true } });
const confirmInstallDownloadedUpdate = vi.fn<UpdateInstallConfirmation>(async () => false);

registerUpdateIpc(updateService, { confirmInstallDownloadedUpdate });

await expect(invokeInstall()).resolves.toEqual({ ok: false, reason: 'cancelled' });
expect(confirmInstallDownloadedUpdate).toHaveBeenCalledTimes(1);
expect(updateService.installDownloadedUpdate).not.toHaveBeenCalled();
});

it('delegates to the update service without confirmation when no update is downloaded', async () => {
const updateService = createUpdateService({
downloaded: false,
installResult: { ok: false, reason: 'not_downloaded' }
});
const confirmInstallDownloadedUpdate = vi.fn<UpdateInstallConfirmation>(async () => true);

registerUpdateIpc(updateService, { confirmInstallDownloadedUpdate });

await expect(invokeInstall()).resolves.toEqual({ ok: false, reason: 'not_downloaded' });
expect(confirmInstallDownloadedUpdate).not.toHaveBeenCalled();
expect(updateService.installDownloadedUpdate).toHaveBeenCalledTimes(1);
});
});

describe('update install confirmation options', () => {
it('warns about restart and possible temporary access loss', () => {
expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.type).toBe('warning');
expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.message).toContain('Install update and restart');
expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.detail).toContain('temporarily lose access');
expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.buttons).toEqual(['Install and restart', 'Cancel']);
expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.defaultId).toBe(1);
expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.cancelId).toBe(1);
});

it('treats only the install button as confirmation', () => {
expect(isInstallUpdateConfirmed(0)).toBe(true);
expect(isInstallUpdateConfirmed(1)).toBe(false);
});
});

function createUpdateService({
downloaded,
installResult
}: {
downloaded: boolean;
installResult: { ok: boolean; reason?: string };
}): UpdateService {
return {
getState: vi.fn(() => ({
info: {
currentVersion: '0.1.10',
latestVersion: null,
releaseName: null,
releaseNotes: null,
checkedAt: null,
status: 'not_checked'
},
download: {
status: downloaded ? 'downloaded' : 'idle',
downloadedBytes: 0,
totalBytes: null,
percent: downloaded ? 100 : null
}
})),
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
installDownloadedUpdate: vi.fn(() => installResult)
} as unknown as UpdateService;
}

function invokeInstall(): Promise<unknown> {
const handler = ipcHandlers.get(INSTALL_DOWNLOADED_UPDATE_CHANNEL);
if (!handler) throw new Error('Install handler was not registered.');
return Promise.resolve(handler({ sender: {} } as Electron.IpcMainInvokeEvent));
}
48 changes: 45 additions & 3 deletions src/main/updates/update-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ipcMain } from 'electron';
import { BrowserWindow, dialog, ipcMain, type MessageBoxOptions } from 'electron';
import {
CHECK_FOR_UPDATES_CHANNEL,
DOWNLOAD_UPDATE_CHANNEL,
Expand All @@ -7,9 +7,51 @@ import {
} from '../../shared/ipc-channels';
import type { UpdateService } from './update-service';

export function registerUpdateIpc(updateService: UpdateService): void {
export const UPDATE_INSTALL_CONFIRMATION_OPTIONS: MessageBoxOptions = {
type: 'warning',
title: 'Install update?',
message: 'Install update and restart Switchify PC?',
detail:
'Switchify PC will close while the update installs. If you rely on Switchify to control this computer, you may temporarily lose access until the app starts again. Make sure you have another way to regain access before continuing.',
buttons: ['Install and restart', 'Cancel'],
defaultId: 1,
cancelId: 1,
noLink: true
};

export type UpdateInstallConfirmation = (event: Electron.IpcMainInvokeEvent) => Promise<boolean>;

export function isInstallUpdateConfirmed(response: number): boolean {
return response === 0;
}

async function showNativeUpdateInstallConfirmation(event: Electron.IpcMainInvokeEvent): Promise<boolean> {
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined;
const result = parentWindow
? await dialog.showMessageBox(parentWindow, UPDATE_INSTALL_CONFIRMATION_OPTIONS)
: await dialog.showMessageBox(UPDATE_INSTALL_CONFIRMATION_OPTIONS);

return isInstallUpdateConfirmed(result.response);
}

export function registerUpdateIpc(
updateService: UpdateService,
options: { confirmInstallDownloadedUpdate?: UpdateInstallConfirmation } = {}
): void {
const confirmInstallDownloadedUpdate = options.confirmInstallDownloadedUpdate ?? showNativeUpdateInstallConfirmation;

ipcMain.handle(GET_UPDATE_STATE_CHANNEL, () => updateService.getState());
ipcMain.handle(CHECK_FOR_UPDATES_CHANNEL, () => updateService.checkForUpdates());
ipcMain.handle(DOWNLOAD_UPDATE_CHANNEL, () => updateService.downloadUpdate());
ipcMain.handle(INSTALL_DOWNLOADED_UPDATE_CHANNEL, () => updateService.installDownloadedUpdate());
ipcMain.handle(INSTALL_DOWNLOADED_UPDATE_CHANNEL, async (event) => {
if (updateService.getState().download.status !== 'downloaded') {
return updateService.installDownloadedUpdate();
}

if (!(await confirmInstallDownloadedUpdate(event))) {
return { ok: false, reason: 'cancelled' };
}

return updateService.installDownloadedUpdate();
});
}