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:
86
src/presentation/electron/UpdateProgressBar.ts
Normal file
86
src/presentation/electron/UpdateProgressBar.ts
Normal 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);
|
||||
}
|
||||
83
src/presentation/electron/Updater.ts
Normal file
83
src/presentation/electron/Updater.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
145
src/presentation/electron/main.ts
Normal file
145
src/presentation/electron/main.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
'use strict';
|
||||
|
||||
// This is main process of Electron, started as first thing when app starts.
|
||||
// This script is running through entire life of the application.
|
||||
// It doesn't have any windows which you can see on screen, opens the main window from here.
|
||||
|
||||
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 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
|
||||
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
let win: BrowserWindow | null;
|
||||
|
||||
// Scheme must be registered before the app is ready
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'app', privileges: { secure: true, standard: true } },
|
||||
]);
|
||||
|
||||
log.transports.file.level = 'silly';
|
||||
if (!process.env.IS_TEST) {
|
||||
Object.assign(console, log.functions); // override console.log, console.warn etc.
|
||||
}
|
||||
|
||||
|
||||
function createWindow() {
|
||||
// Create the browser window.
|
||||
const size = getWindowSize(1350, 955);
|
||||
win = new BrowserWindow({
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
webPreferences: {
|
||||
contextIsolation: false, // To reach node https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1285
|
||||
// Use pluginOptions.nodeIntegration, leave this alone
|
||||
// See https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration
|
||||
nodeIntegration: (process.env
|
||||
.ELECTRON_NODE_INTEGRATION as unknown) as boolean,
|
||||
},
|
||||
// https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/recipes.html#set-tray-icon
|
||||
icon: path.join(__static, 'icon.png'),
|
||||
});
|
||||
|
||||
win.setMenuBarVisibility(false);
|
||||
configureExternalsUrlsOpenBrowser(win);
|
||||
loadApplication(win);
|
||||
|
||||
win.on('closed', () => {
|
||||
win = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Quit when all windows are closed.
|
||||
app.on('window-all-closed', () => {
|
||||
// On macOS it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (win === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on('ready', async () => {
|
||||
if (isDevelopment && !process.env.IS_TEST) {
|
||||
// Install Vue Devtools
|
||||
try {
|
||||
await installExtension(VUEJS_DEVTOOLS);
|
||||
} catch (e) {
|
||||
console.error('Vue Devtools failed to install:', e.toString()); // tslint:disable-line:no-console
|
||||
}
|
||||
}
|
||||
createWindow();
|
||||
});
|
||||
|
||||
// Exit cleanly on request from parent process in development mode.
|
||||
if (isDevelopment) {
|
||||
if (process.platform === 'win32') {
|
||||
process.on('message', (data) => {
|
||||
if (data === 'graceful-exit') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
process.on('SIGTERM', () => {
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function loadApplication(window: BrowserWindow) {
|
||||
if (process.env.WEBPACK_DEV_SERVER_URL) {
|
||||
// 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) {
|
||||
window.webContents.openDevTools();
|
||||
}
|
||||
} else {
|
||||
createProtocol('app');
|
||||
// Load the index.html when not in development
|
||||
loadUrlWithNodeWorkaround(win, 'app://./index.html');
|
||||
const updater = setupAutoUpdater();
|
||||
updater.checkForUpdatesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
function configureExternalsUrlsOpenBrowser(window: BrowserWindow) {
|
||||
window.webContents.on('new-window', (event, url) => { // handle redirect
|
||||
if (url !== win.webContents.getURL()) {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Workaround for https://github.com/electron/electron/issues/19554 otherwise fs does not work
|
||||
function loadUrlWithNodeWorkaround(window: BrowserWindow, url: string) {
|
||||
setTimeout(() => {
|
||||
window.loadURL(url);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function getWindowSize(idealWidth: number, idealHeight: number) {
|
||||
let { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
// To ensure not creating a screen bigger than current screen size
|
||||
// Not using "enableLargerThanScreen" as it's macOS only (see https://www.electronjs.org/docs/api/browser-window)
|
||||
width = Math.min(width, idealWidth);
|
||||
height = Math.min(height, idealHeight);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user