From c8847d42de6882f1904e4c7941a9d956df4ef56a Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 21 Jun 2026 16:14:38 +0100 Subject: [PATCH] Confirm before installing updates --- src/main/updates/update-ipc.test.ts | 121 ++++++++++++++++++++++++++++ src/main/updates/update-ipc.ts | 48 ++++++++++- 2 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 src/main/updates/update-ipc.test.ts diff --git a/src/main/updates/update-ipc.test.ts b/src/main/updates/update-ipc.test.ts new file mode 100644 index 0000000..56c8ba5 --- /dev/null +++ b/src/main/updates/update-ipc.test.ts @@ -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(); + +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(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(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(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 { + 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)); +} diff --git a/src/main/updates/update-ipc.ts b/src/main/updates/update-ipc.ts index b17bd98..7588d3a 100644 --- a/src/main/updates/update-ipc.ts +++ b/src/main/updates/update-ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron'; +import { BrowserWindow, dialog, ipcMain, type MessageBoxOptions } from 'electron'; import { CHECK_FOR_UPDATES_CHANNEL, DOWNLOAD_UPDATE_CHANNEL, @@ -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; + +export function isInstallUpdateConfirmed(response: number): boolean { + return response === 0; +} + +async function showNativeUpdateInstallConfirmation(event: Electron.IpcMainInvokeEvent): Promise { + 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(); + }); }