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.
This commit is contained in:
undergroundwires
2021-09-11 11:04:08 +01:00
parent 2f0321f315
commit ddf417a16a
7 changed files with 198 additions and 22 deletions

View File

@@ -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);
}

View File

@@ -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<void>;
}
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);
}
}

View File

@@ -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 };
}