From 691ad178ea9d6224f6545583064f37f06b99108b Mon Sep 17 00:00:00 2001 From: Arthur Lobo <64273139+ArthurLobopro@users.noreply.github.com> Date: Thu, 12 Dec 2024 21:58:01 -0300 Subject: [PATCH] feat: add custom dialog option (#126) * feat: add custom dialog option * chore: add missing semicolons * feat: add onNotifyUser hook and makeUserNotifier * fix: Import Event interface from electron * beep boop * add tests to makeUserNotifier * chore: minor tweaks --------- Co-authored-by: Erick Zhao Co-authored-by: David Sanders --- .gitignore | 1 + eslint.config.mjs | 15 ++++- package.json | 1 + src/index.ts | 112 ++++++++++++++++++++++++++++++++----- test/__mocks__/electron.ts | 5 +- test/index.test.ts | 64 ++++++++++++++++++++- yarn.lock | 5 ++ 7 files changed, 181 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 1a5b4d2..d675c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ coverage node_modules dist +.eslintcache diff --git a/eslint.config.mjs b/eslint.config.mjs index 1266151..f59da18 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,18 @@ // @ts-check - +import globals from 'globals'; import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import eslintConfigPrettier from 'eslint-config-prettier'; -export default [eslint.configs.recommended, eslintConfigPrettier, ...tseslint.configs.recommended]; +export default [ + eslint.configs.recommended, + eslintConfigPrettier, + ...tseslint.configs.recommended, + { + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, +]; diff --git a/package.json b/package.json index 608e99c..ae49b6f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "electron": "^33.2.1", "eslint": "^9.15.0", "eslint-config-prettier": "^9.1.0", + "globals": "^15.13.0", "husky": "^9.1.7", "jest": "^29.0.0", "lint-staged": "^15.2.10", diff --git a/src/index.ts b/src/index.ts index 721cc20..582eafd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import os from 'node:os'; import path from 'node:path'; import { format } from 'node:util'; -import { app, autoUpdater, dialog } from 'electron'; +import { app, autoUpdater, dialog, Event } from 'electron'; export interface ILogger { log(message: string): void; @@ -46,6 +46,37 @@ export interface IStaticUpdateSource { export type IUpdateSource = IElectronUpdateServiceSource | IStaticUpdateSource; +export interface IUpdateInfo { + event: Event; + releaseNotes: string; + releaseName: string; + releaseDate: Date; + updateURL: string; +} + +export interface IUpdateDialogStrings { + /** + * @param {String} title The title of the dialog box. + * Defaults to `Application Update` + */ + title?: string; + /** + * @param {String} detail The text of the dialog box. + * Defaults to `A new version has been downloaded. Restart the application to apply the updates.` + */ + detail?: string; + /** + * @param {String} restartButtonText The text of the restart button. + * Defaults to `Restart` + */ + restartButtonText?: string; + /** + * @param {String} laterButtonText The text of the later button. + * Defaults to `Later` + */ + laterButtonText?: string; +} + export interface IUpdateElectronAppOptions { /** * @param {String} repo A GitHub repository in the format `owner/repo`. @@ -75,6 +106,13 @@ export interface IUpdateElectronAppOptions { * prompted to apply the update immediately after download. */ readonly notifyUser?: boolean; + /** + * Optional callback that replaces the default user prompt dialog whenever the 'update-downloaded' event + * is fired. Only runs if {@link notifyUser} is `true`. + * + * @param info - Information pertaining to the available update. + */ + readonly onNotifyUser?: (info: IUpdateInfo) => void; } // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -181,17 +219,23 @@ function initUpdater(opts: ReturnType) { (event, releaseNotes, releaseName, releaseDate, updateURL) => { log('update-downloaded', [event, releaseNotes, releaseName, releaseDate, updateURL]); - const dialogOpts: Electron.MessageBoxOptions = { - type: 'info', - buttons: ['Restart', 'Later'], - title: 'Application Update', - message: process.platform === 'win32' ? releaseNotes : releaseName, - detail: - 'A new version has been downloaded. Restart the application to apply the updates.', - }; - - dialog.showMessageBox(dialogOpts).then(({ response }) => { - if (response === 0) autoUpdater.quitAndInstall(); + if (typeof opts.onNotifyUser !== 'function') { + assert( + opts.onNotifyUser === undefined, + 'onNotifyUser option must be a callback function or undefined', + ); + log('update-downloaded: notifyUser is true, opening default dialog'); + opts.onNotifyUser = makeUserNotifier(); + } else { + log('update-downloaded: notifyUser is true, running custom onNotifyUser callback'); + } + + opts.onNotifyUser({ + event, + releaseNotes, + releaseDate, + releaseName, + updateURL, }); }, ); @@ -204,6 +248,41 @@ function initUpdater(opts: ReturnType) { }, ms(updateInterval)); } +/** + * Helper function that generates a callback for use with {@link IUpdateElectronAppOptions.onNotifyUser}. + * + * @param dialogProps - Text to display in the dialog. + */ +export function makeUserNotifier(dialogProps?: IUpdateDialogStrings): (info: IUpdateInfo) => void { + const defaultDialogMessages = { + title: 'Application Update', + detail: 'A new version has been downloaded. Restart the application to apply the updates.', + restartButtonText: 'Restart', + laterButtonText: 'Later', + }; + + const assignedDialog = Object.assign({}, defaultDialogMessages, dialogProps); + + return (info: IUpdateInfo) => { + const { releaseNotes, releaseName } = info; + const { title, restartButtonText, laterButtonText, detail } = assignedDialog; + + const dialogOpts: Electron.MessageBoxOptions = { + type: 'info', + buttons: [restartButtonText, laterButtonText], + title, + message: process.platform === 'win32' ? releaseNotes : releaseName, + detail, + }; + + dialog.showMessageBox(dialogOpts).then(({ response }) => { + if (response === 0) { + autoUpdater.quitAndInstall(); + } + }); + }; +} + function guessRepo() { const pkgBuf = fs.readFileSync(path.join(app.getAppPath(), 'package.json')); const pkg = JSON.parse(pkgBuf.toString()); @@ -220,7 +299,12 @@ function validateInput(opts: IUpdateElectronAppOptions) { logger: console, notifyUser: true, }; - const { host, updateInterval, logger, notifyUser } = Object.assign({}, defaults, opts); + + const { host, updateInterval, logger, notifyUser, onNotifyUser } = Object.assign( + {}, + defaults, + opts, + ); let updateSource = opts.updateSource; // Handle migration from old properties + default to update service @@ -260,5 +344,5 @@ function validateInput(opts: IUpdateElectronAppOptions) { assert(logger && typeof logger.log, 'function'); - return { updateSource, updateInterval, logger, notifyUser }; + return { updateSource, updateInterval, logger, notifyUser, onNotifyUser }; } diff --git a/test/__mocks__/electron.ts b/test/__mocks__/electron.ts index bbfe2eb..0d06e8b 100644 --- a/test/__mocks__/electron.ts +++ b/test/__mocks__/electron.ts @@ -24,10 +24,9 @@ module.exports = { setFeedURL: () => { /* no-op */ }, + quitAndInstall: jest.fn(), }, dialog: { - showMessageBox: () => { - /* no-op */ - }, + showMessageBox: jest.fn(), }, }; diff --git a/test/index.test.ts b/test/index.test.ts index fd0420b..80452fc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,10 +2,10 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { updateElectronApp } from '..'; -const repo = 'some-owner/some-repo'; +import { autoUpdater, dialog } from 'electron'; -jest.mock('electron'); +import { updateElectronApp, makeUserNotifier, IUpdateInfo, IUpdateDialogStrings } from '../src'; +const repo = 'some-owner/some-repo'; beforeEach(() => { jest.useFakeTimers(); @@ -59,3 +59,61 @@ describe('updateElectronApp', () => { }); }); }); + +describe('makeUserNotifier', () => { + const fakeUpdateInfo: IUpdateInfo = { + event: {} as Electron.Event, + releaseNotes: 'new release', + releaseName: 'v13.3.7', + releaseDate: new Date(), + updateURL: 'https://fake-update.url', + }; + + beforeEach(() => { + jest.mocked(dialog.showMessageBox).mockReset(); + }); + + it('is a function that returns a callback function', () => { + expect(typeof makeUserNotifier).toBe('function'); + expect(typeof makeUserNotifier()).toBe('function'); + }); + + describe('callback', () => { + it.each([ + ['does', 0, 1], + ['does not', 1, 0], + ])('%s call autoUpdater.quitAndInstall if the user responds with %i', (_, response, called) => { + jest + .mocked(dialog.showMessageBox) + .mockResolvedValueOnce({ response, checkboxChecked: false }); + const notifier = makeUserNotifier(); + notifier(fakeUpdateInfo); + + expect(dialog.showMessageBox).toHaveBeenCalled(); + // quitAndInstall is only called after the showMessageBox promise resolves + process.nextTick(() => { + expect(autoUpdater.quitAndInstall).toHaveBeenCalledTimes(called); + }); + }); + }); + + it('can customize dialog properties', () => { + const strings: IUpdateDialogStrings = { + title: 'Custom Update Title', + detail: 'Custom update details', + restartButtonText: 'Custom restart string', + laterButtonText: 'Maybe not', + }; + + jest.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false }); + const notifier = makeUserNotifier(strings); + notifier(fakeUpdateInfo); + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: [strings.restartButtonText, strings.laterButtonText], + title: strings.title, + detail: strings.detail, + }), + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index a9b366b..e0c865d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2032,6 +2032,11 @@ globals@^14.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== +globals@^15.13.0: + version "15.13.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-15.13.0.tgz#bbec719d69aafef188ecd67954aae76a696010fc" + integrity sha512-49TewVEz0UxZjr1WYYsWpPrhyC/B/pA8Bq0fUmet2n+eR7yn0IvNzNaoBwnK6mdkzcN+se7Ez9zUgULTz2QH4g== + globalthis@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.3.tgz#5852882a52b80dc301b0660273e1ed082f0b6ccf"