From ddf417a16a79551b43576befab0541ea08487969 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Sat, 11 Sep 2021 11:04:08 +0100 Subject: [PATCH] Add new UX for optionally downloading updates Before we used native method from electron for updating and notifying (`checkForUpdatesAndNotify`). It simply checked if there's an update, downloaded it, applied in the background and showed OS notification. The flow is now updated. Updates will be checked, user will be asked to confirm about whether to download and apply the updates, then a UI with progress bar will be shown and user will be asked to restart the application. This commit also moves electron related logic to `/electron/` folder (as there are now multiple files) to keep them structured. Also the electon entrypoint `background.ts` is renamed to `main.ts`. The reason it was named `background.ts` by vue-cli-plugin-electron-builder was to remove the confusion between `main.ts` of Vue itself. However, as they are kept in different folders, but this is not the case for us. Better than `checkForUpdatesAndNotify`. Organizes electron desktop app logic in same folder to allow using multiple files in a structured manner. --- docs/presentation.md | 3 +- package-lock.json | 25 ++++-- package.json | 1 + .../electron/UpdateProgressBar.ts | 86 +++++++++++++++++++ src/presentation/electron/Updater.ts | 83 ++++++++++++++++++ .../{background.ts => electron/main.ts} | 20 ++--- vue.config.js | 2 +- 7 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 src/presentation/electron/UpdateProgressBar.ts create mode 100644 src/presentation/electron/Updater.ts rename src/presentation/{background.ts => electron/main.ts} (85%) diff --git a/docs/presentation.md b/docs/presentation.md index 2398a5e1..61eb1a4d 100644 --- a/docs/presentation.md +++ b/docs/presentation.md @@ -12,7 +12,8 @@ - [**`Shared/`**](./../src/presentation/components/Shared): Contains Vue components and component helpers that are shared across other components. - [**`styles/`**](./../src/presentation/styles/): Contains shared styles used throughout different components. - [**`main.ts`**](./../src/presentation/main.ts): Application entry point that mounts and starts Vue application. - - [**`background.ts`**](./../src/presentation/background.ts): Main process of Electron, started as first thing when app starts. + - [`electron/`](./../src/presentation/electron/): Electron configuration for the desktop application. + - [**`main.ts`**](./../src/presentation/main.ts): Main process of Electron, started as first thing when app starts. - [**`/public/`**](./../public/): Contains static assets that will simply be copied and not go through webpack. - [**`/vue.config.js`**](./../vue.config.js): Global Vue CLI configurations loaded by `@vue/cli-service` - [**`/postcss.config.js`**](./../postcss.config.js): PostCSS configurations that are used by Vue CLI internally diff --git a/package-lock.json b/package-lock.json index 2659f4eb..9e00c04e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "requires": true, "packages": { "": { - "version": "0.10.2", + "version": "0.10.3", "hasInstallScript": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "^1.2.35", @@ -17,6 +17,7 @@ "ace-builds": "^1.4.12", "core-js": "^3.12.1", "cross-fetch": "^3.1.4", + "electron-progressbar": "^2.0.1", "file-saver": "^2.0.5", "inversify": "^5.1.1", "liquor-tree": "^0.2.70", @@ -7358,6 +7359,14 @@ "integrity": "sha512-J5Ew3axdk7W4jzzxKLSAi1sqbcAoo9CzHuBVsG0tT47j256xKulNrWFf3lZmHJ1KDXOQUcuwOngQF0jjmpEdpw==", "dev": true }, + "node_modules/electron-progressbar": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/electron-progressbar/-/electron-progressbar-2.0.1.tgz", + "integrity": "sha512-+N60GX2q+KH5OvZXxwtjMTZB/1AyxriFd95vOnR3sOfNpvz+30LMsM0a9SnEivZE6N8Djy7F3z4TY8pLs8aopw==", + "dependencies": { + "extend": "^3.0.1" + } + }, "node_modules/electron-publish": { "version": "22.9.1", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-22.9.1.tgz", @@ -8038,8 +8047,7 @@ "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "node_modules/extend-shallow": { "version": "3.0.2", @@ -26826,6 +26834,14 @@ "integrity": "sha512-J5Ew3axdk7W4jzzxKLSAi1sqbcAoo9CzHuBVsG0tT47j256xKulNrWFf3lZmHJ1KDXOQUcuwOngQF0jjmpEdpw==", "dev": true }, + "electron-progressbar": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/electron-progressbar/-/electron-progressbar-2.0.1.tgz", + "integrity": "sha512-+N60GX2q+KH5OvZXxwtjMTZB/1AyxriFd95vOnR3sOfNpvz+30LMsM0a9SnEivZE6N8Djy7F3z4TY8pLs8aopw==", + "requires": { + "extend": "^3.0.1" + } + }, "electron-publish": { "version": "22.9.1", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-22.9.1.tgz", @@ -27387,8 +27403,7 @@ "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "extend-shallow": { "version": "3.0.2", diff --git a/package.json b/package.json index 638bf4ff..bd6717b1 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "ace-builds": "^1.4.12", "core-js": "^3.12.1", "cross-fetch": "^3.1.4", + "electron-progressbar": "^2.0.1", "file-saver": "^2.0.5", "inversify": "^5.1.1", "liquor-tree": "^0.2.70", diff --git a/src/presentation/electron/UpdateProgressBar.ts b/src/presentation/electron/UpdateProgressBar.ts new file mode 100644 index 00000000..2b00ff23 --- /dev/null +++ b/src/presentation/electron/UpdateProgressBar.ts @@ -0,0 +1,86 @@ +import ProgressBar from 'electron-progressbar'; +import { ProgressInfo } from 'electron-builder'; +import { app } from 'electron'; +import log from 'electron-log'; + +export class UpdateProgressBar { + private progressBar: ProgressBar; + private showingProgress = false; + public showIndeterminateState() { + this.progressBar?.close(); + this.progressBar = progressBarFactory.createWithIndeterminateState(); + } + public showProgress(progress: ProgressInfo) { + if (!this.showingProgress) { // First time showing progress + this.progressBar?.close(); + this.showingProgress = true; + } else { + updateProgressBar(progress, this.progressBar); + } + } + public showError(e: Error) { + const reportUpdateError = () => { + this.progressBar.detail = + 'An error occurred while fetching updates.\n' + + (e && e.message ? e.message : e); + this.progressBar._window.setClosable(true); + }; + if (this.progressBar._window) { + reportUpdateError(); + } else { + this.progressBar.on('ready', () => reportUpdateError()); + } + } + public close() { + if (!this.progressBar.isCompleted()) { + this.progressBar.close(); + } + } +} + +function getUpdatePercent(progress: ProgressInfo) { + let percent = progress.percent; + if (percent) { + percent = Math.round(percent * 100) / 100; + } + return percent; +} + +const progressBarFactory = { + createWithIndeterminateState: () => { + return new ProgressBar({ + title: `${app.name} Update`, + text: `Downloading ${app.name} update...`, + }); + }, + createWithPercentile: (initialProgress: ProgressInfo) => { + const percent = getUpdatePercent(initialProgress); + const progressBar = new ProgressBar({ + indeterminate: false, + title: `${app.name} Update`, + text: `Downloading ${app.name} update...`, + detail: `${percent}% ...`, + initialValue: percent, + }); + progressBar + .on('completed', () => { + progressBar.detail = 'Download completed.'; + }) + .on('aborted', (value) => { + log.info(`progress aborted... ${value}`); + }) + .on('progress', (value) => { + progressBar.detail = `${value}% ...`; + }) + .on('ready', () => { + // initialValue doesn't set the UI, so this is needed to render it correctly + progressBar.value = percent; + }); + return progressBar; + }, +}; + + +function updateProgressBar(progress: ProgressInfo, progressBar: ProgressBar) { + progressBar.value = getUpdatePercent(progress); +} diff --git a/src/presentation/electron/Updater.ts b/src/presentation/electron/Updater.ts new file mode 100644 index 00000000..dadbdcd2 --- /dev/null +++ b/src/presentation/electron/Updater.ts @@ -0,0 +1,83 @@ +import { app, dialog } from 'electron'; +import { autoUpdater, UpdateInfo } from 'electron-updater'; +import { ProgressInfo } from 'electron-builder'; +import log from 'electron-log'; +import { UpdateProgressBar } from './UpdateProgressBar'; + +interface IUpdater { + checkForUpdatesAsync(): Promise; +} + +export function setupAutoUpdater(): IUpdater { + autoUpdater.logger = log; + autoUpdater.on('error', (error: Error) => { + log.error('@error@\n', error); + }); + autoUpdater.on('update-available', async (info: UpdateInfo) => { + log.info('@update-available@\n', info); + await handleAvailableUpdateAsync(); + }); + return { + checkForUpdatesAsync: async () => { + await autoUpdater.checkForUpdates(); + }, + }; +} + +async function handleAvailableUpdateAsync() { + if (!await dialogs.askDownloadAndInstallAsync()) { + return; + } + autoUpdater.downloadUpdate(); + handleUpdateProgress(); +} + +function handleUpdateProgress() { + const progressBar = new UpdateProgressBar(); + progressBar.showIndeterminateState(); + autoUpdater.on('error', (e) => { + progressBar.showError(e); + }); + autoUpdater.on('download-progress', (progress: ProgressInfo) => { + /* + On macOS, download-progress event is not called + so the indeterminate progress will continue until download is finished. + */ + log.info('@update-progress@\n', progress); + progressBar.showProgress(progress); + }); + autoUpdater.on('update-downloaded', async (info: UpdateInfo) => { + log.info('@update-downloaded@\n', info); + progressBar.close(); + await handleUpdateDownloadedAsync(); + }); +} + +const dialogs = { + askDownloadAndInstallAsync: async () => { + const updateDialogResult = await dialog.showMessageBox({ + type: 'question', + buttons: ['Install', 'Not now' ], + title: 'Confirm Update', + message: 'Update available.\n\nWould you like to download and install new version?', + detail: 'Application will automatically restart to apply update after download', + }); + return updateDialogResult.response === 0; + }, + askRestartAndInstallAsync: async () => { + const installDialogResult = await dialog.showMessageBox({ + type: 'question', + buttons: ['Install and restart', 'Later'], + defaultId: 0, + message: 'A new version of ' + app.name + ' has been downloaded', + detail: 'It will be installed the next time you restart the application', + }); + return installDialogResult.response === 0; + }, +}; + +async function handleUpdateDownloadedAsync() { + if (await dialogs.askRestartAndInstallAsync()) { + setTimeout(() => autoUpdater.quitAndInstall(), 1); + } +} diff --git a/src/presentation/background.ts b/src/presentation/electron/main.ts similarity index 85% rename from src/presentation/background.ts rename to src/presentation/electron/main.ts index 3d00040e..330e15b9 100644 --- a/src/presentation/background.ts +++ b/src/presentation/electron/main.ts @@ -8,9 +8,8 @@ import { app, protocol, BrowserWindow, shell, screen } from 'electron'; import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'; import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; import path from 'path'; -import { autoUpdater } from 'electron-updater'; import log from 'electron-log'; - +import { setupAutoUpdater } from './Updater'; const isDevelopment = process.env.NODE_ENV !== 'production'; declare const __static: string; // https://github.com/electron-userland/electron-webpack/issues/172 @@ -24,8 +23,6 @@ protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { secure: true, standard: true } }, ]); -// Setup logging -autoUpdater.logger = log; // https://www.electron.build/auto-update#debugging log.transports.file.level = 'silly'; if (!process.env.IS_TEST) { Object.assign(console, log.functions); // override console.log, console.warn etc. @@ -90,14 +87,6 @@ app.on('ready', async () => { createWindow(); }); -// See electron-builder issue "checkForUpdatesAndNotify updates but does not notify on Windows 10" -// https://github.com/electron-userland/electron-builder/issues/2700 -// https://github.com/electron/electron/issues/10864 -if (process.platform === 'win32') { - // https://docs.microsoft.com/en-us/windows/win32/shell/appid#how-to-form-an-application-defined-appusermodelid - app.setAppUserModelId('Undergroundwires.PrivacySexy'); -} - // Exit cleanly on request from parent process in development mode. if (isDevelopment) { if (process.platform === 'win32') { @@ -118,14 +107,14 @@ function loadApplication(window: BrowserWindow) { // Load the url of the dev server if in development mode loadUrlWithNodeWorkaround(win, process.env.WEBPACK_DEV_SERVER_URL as string); if (!process.env.IS_TEST) { - win.webContents.openDevTools(); + window.webContents.openDevTools(); } } else { createProtocol('app'); // Load the index.html when not in development loadUrlWithNodeWorkaround(win, 'app://./index.html'); - // tslint:disable-next-line:max-line-length - autoUpdater.checkForUpdatesAndNotify(); // https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/recipes.html#check-for-updates-in-background-js-ts + const updater = setupAutoUpdater(); + updater.checkForUpdatesAsync(); } } @@ -153,3 +142,4 @@ function getWindowSize(idealWidth: number, idealHeight: number) { height = Math.min(height, idealHeight); return { width, height }; } + diff --git a/vue.config.js b/vue.config.js index 65b20d9c..b18e2300 100644 --- a/vue.config.js +++ b/vue.config.js @@ -20,7 +20,7 @@ module.exports = { pluginOptions: { // https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/guide.html#native-modules electronBuilder: { - mainProcessFile: './src/presentation/background.ts', // https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/configuration.html#webpack-configuration + mainProcessFile: './src/presentation/electron/main.ts', // https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/configuration.html#webpack-configuration nodeIntegration: true, // required to reach Node.js APIs for environment specific logic // https://www.electron.build/configuration/configuration builderOptions: {