From 410bcd82445097c29c9fcf0eabf7af9ebcb93c1e Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Wed, 13 Oct 2021 21:25:09 +0100 Subject: [PATCH] Add semi-automatic update support for macOS For fully automatic macOS updates, electron-updater requires: 1. Distributing macOS file as .zip (electron-userland/electron-builder#2199) 2. Code signing for the application privacy.sexy as of today lacks both the distribution and code signing. This commit introduces auto-updates through automatically checking for updates, downloading them but requiring user to drag application icons to Applications by opening dmg file. This commit also fixes: 1. Progress state in update progress bar not being shown. 2. Downloading updates were being triggered even though it was not desired as downloads are being handled using different based on OS and user choice. In the end it refactors the code for handling updates of two different kinds, and making message dialog use enums for results instead of response integers as well as setting default and cancel button behavior. Refactorings make behaviors more explicit and extends the control. --- .../electron/Update/AutoUpdater.ts | 74 ++++++++++ .../electron/Update/ManualUpdater.ts | 130 ++++++++++++++++++ .../{ => Update}/UpdateProgressBar.ts | 24 ++-- src/presentation/electron/Update/Updater.ts | 40 ++++++ src/presentation/electron/Updater.ts | 83 ----------- src/presentation/electron/main.ts | 23 +++- vue.config.js | 18 +-- 7 files changed, 284 insertions(+), 108 deletions(-) create mode 100644 src/presentation/electron/Update/AutoUpdater.ts create mode 100644 src/presentation/electron/Update/ManualUpdater.ts rename src/presentation/electron/{ => Update}/UpdateProgressBar.ts (76%) create mode 100644 src/presentation/electron/Update/Updater.ts delete mode 100644 src/presentation/electron/Updater.ts diff --git a/src/presentation/electron/Update/AutoUpdater.ts b/src/presentation/electron/Update/AutoUpdater.ts new file mode 100644 index 00000000..7682e891 --- /dev/null +++ b/src/presentation/electron/Update/AutoUpdater.ts @@ -0,0 +1,74 @@ +import { app, dialog } from 'electron'; +import { autoUpdater, UpdateInfo } from 'electron-updater'; +import { ProgressInfo } from 'electron-builder'; +import { UpdateProgressBar } from './UpdateProgressBar'; +import log from 'electron-log'; + +export async function handleAutoUpdateAsync() { + if (await askDownloadAndInstallAsync() === DownloadDialogResult.NotNow) { + return; + } + startHandlingUpdateProgress(); + await autoUpdater.downloadUpdate(); +} + +function startHandlingUpdateProgress() { + 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.debug('@download-progress@\n', progress); + progressBar.showProgress(progress); + }); + autoUpdater.on('update-downloaded', async (info: UpdateInfo) => { + log.info('@update-downloaded@\n', info); + progressBar.close(); + await handleUpdateDownloadedAsync(); + }); +} + +async function handleUpdateDownloadedAsync() { + if (await askRestartAndInstallAsync() === InstallDialogResult.NotNow) { + return; + } + setTimeout(() => autoUpdater.quitAndInstall(), 1); +} + +enum DownloadDialogResult { + Install = 0, + NotNow = 1, +} +async function askDownloadAndInstallAsync(): Promise { + 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', + defaultId: DownloadDialogResult.Install, + cancelId: DownloadDialogResult.NotNow, + }); + return updateDialogResult.response; +} + +enum InstallDialogResult { + InstallAndRestart = 0, + NotNow = 1, +} +async function askRestartAndInstallAsync(): Promise { + const installDialogResult = await dialog.showMessageBox({ + type: 'question', + buttons: ['Install and restart', 'Later'], + message: 'A new version of ' + app.name + ' has been downloaded', + detail: 'It will be installed the next time you restart the application', + defaultId: InstallDialogResult.InstallAndRestart, + cancelId: InstallDialogResult.NotNow, + }); + return installDialogResult.response; +} diff --git a/src/presentation/electron/Update/ManualUpdater.ts b/src/presentation/electron/Update/ManualUpdater.ts new file mode 100644 index 00000000..d476e5e1 --- /dev/null +++ b/src/presentation/electron/Update/ManualUpdater.ts @@ -0,0 +1,130 @@ +import { app, dialog, shell } from 'electron'; +import { ProjectInformation } from '@/domain/ProjectInformation'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { UpdateInfo } from 'electron-updater'; +import { UpdateProgressBar } from './UpdateProgressBar'; +import fs from 'fs'; +import path from 'path'; +import log from 'electron-log'; +import fetch from 'cross-fetch'; + +export function requiresManualUpdate(): boolean { + return process.platform === 'darwin'; +} + +export async function handleManualUpdateAsync(info: UpdateInfo) { + const result = await askForVisitingWebsiteForManualUpdateAsync(); + if (result === ManualDownloadDialogResult.NoAction) { + return; + } + const project = new ProjectInformation( + process.env.VUE_APP_NAME, + info.version, + process.env.VUE_APP_REPOSITORY_URL, + process.env.VUE_APP_HOMEPAGE_URL, + ); + if (result === ManualDownloadDialogResult.VisitReleasesPage) { + await shell.openExternal(project.releaseUrl); + } else if (result === ManualDownloadDialogResult.UpdateNow) { + await downloadAsync(info, project); + } +} + +enum ManualDownloadDialogResult { + NoAction = 0, + UpdateNow = 1, + VisitReleasesPage = 2, +} +async function askForVisitingWebsiteForManualUpdateAsync(): Promise { + const visitPageResult = await dialog.showMessageBox({ + type: 'info', + buttons: [ + 'Not now', // First button is shown at bottom after some space in macOS and has default cancel behavior + 'Download and manually update', + 'Visit releases page', + ], + message: 'Update available\n\nWould you like to update manually?', + detail: + 'There are new updates available.' + + ' privacy.sexy does not support fully auto-update for macOS due to code signing costs.' + + ' Please manually update your version, because newer versions fix issues and improve privacy and security.', + defaultId: ManualDownloadDialogResult.UpdateNow, + cancelId: ManualDownloadDialogResult.NoAction, + }); + return visitPageResult.response; +} + +async function downloadAsync(info: UpdateInfo, project: ProjectInformation) { + log.info('Downloading update manually'); + const progressBar = new UpdateProgressBar(); + progressBar.showIndeterminateState(); + try { + const filePath = `${path.dirname(app.getPath('temp'))}/privacy.sexy/${info.version}-installer.dmg`; + const parentFolder = path.dirname(filePath); + if (fs.existsSync(filePath)) { + log.info('Update is already downloaded'); + await fs.promises.unlink(filePath); + log.info(`Deleted ${filePath}`); + } else { + await fs.promises.mkdir(parentFolder, { recursive: true }); + } + const dmgFileUrl = project.getDownloadUrl(OperatingSystem.macOS); + await downloadFileWithProgressAsync(dmgFileUrl, filePath, + (percentage) => { progressBar.showPercentage(percentage); }); + await shell.openPath(filePath); + progressBar.close(); + app.quit(); + } catch (e) { + progressBar.showError(e); + } +} + +type ProgressCallback = (progress: number) => void; + +async function downloadFileWithProgressAsync( + url: string, filePath: string, progressHandler: ProgressCallback) { + // We don't download through autoUpdater as it cannot download DMG but requires distributing ZIP + log.info(`Fetching ${url}`); + const response = await fetch(url); + if (!response.ok) { + throw Error(`Unable to download, server returned ${response.status} ${response.statusText}`); + } + const contentLength = +response.headers.get('content-length'); + const writer = fs.createWriteStream(filePath); + log.info(`Writing to ${filePath}, content length: ${contentLength}`); + if (!contentLength) { + log.error('Unknown content-length', Array.from(response.headers.entries())); + progressHandler = () => { /* do nothing */ }; + } + const reader = getReader(response); + if (!reader) { + throw new Error('No response body'); + } + await streamWithProgressAsync(contentLength, reader, writer, progressHandler); +} + +async function streamWithProgressAsync( + totalLength: number, + readStream: NodeJS.ReadableStream, + writeStream: fs.WriteStream, + progressHandler: ProgressCallback): Promise { + let receivedLength = 0; + for await (const chunk of readStream) { + if (!chunk) { + throw Error('Empty chunk received during download'); + } + writeStream.write(Buffer.from(chunk)); + receivedLength += chunk.length; + const percentage = Math.floor((receivedLength / totalLength) * 100); + progressHandler(percentage); + log.debug(`Received ${receivedLength} of ${totalLength}`); + } + log.info(`Downloaded successfully`); +} + +function getReader(response: Response): NodeJS.ReadableStream { + // On browser, we could use browser API response.body.getReader() + // But here, we use cross-fetch that gets node-fetch on a node application + // This API is node-fetch specific, see https://github.com/node-fetch/node-fetch#streams + return response.body as unknown as NodeJS.ReadableStream; +} diff --git a/src/presentation/electron/UpdateProgressBar.ts b/src/presentation/electron/Update/UpdateProgressBar.ts similarity index 76% rename from src/presentation/electron/UpdateProgressBar.ts rename to src/presentation/electron/Update/UpdateProgressBar.ts index 2b00ff23..dbc3d959 100644 --- a/src/presentation/electron/UpdateProgressBar.ts +++ b/src/presentation/electron/Update/UpdateProgressBar.ts @@ -11,11 +11,16 @@ export class UpdateProgressBar { this.progressBar = progressBarFactory.createWithIndeterminateState(); } public showProgress(progress: ProgressInfo) { + const percentage = getUpdatePercent(progress); + this.showPercentage(percentage); + } + public showPercentage(percentage: number) { if (!this.showingProgress) { // First time showing progress this.progressBar?.close(); this.showingProgress = true; + this.progressBar = progressBarFactory.createWithPercentile(percentage); } else { - updateProgressBar(progress, this.progressBar); + updateProgressBar(percentage, this.progressBar); } } public showError(e: Error) { @@ -53,34 +58,33 @@ const progressBarFactory = { text: `Downloading ${app.name} update...`, }); }, - createWithPercentile: (initialProgress: ProgressInfo) => { - const percent = getUpdatePercent(initialProgress); + createWithPercentile: (initialPercentage: number) => { const progressBar = new ProgressBar({ indeterminate: false, title: `${app.name} Update`, text: `Downloading ${app.name} update...`, - detail: `${percent}% ...`, - initialValue: percent, + detail: `${initialPercentage}% ...`, + initialValue: initialPercentage, }); progressBar .on('completed', () => { progressBar.detail = 'Download completed.'; }) - .on('aborted', (value) => { + .on('aborted', (value: number) => { log.info(`progress aborted... ${value}`); }) - .on('progress', (value) => { + .on('progress', (value: number) => { progressBar.detail = `${value}% ...`; }) .on('ready', () => { // initialValue doesn't set the UI, so this is needed to render it correctly - progressBar.value = percent; + progressBar.value = initialPercentage; }); return progressBar; }, }; -function updateProgressBar(progress: ProgressInfo, progressBar: ProgressBar) { - progressBar.value = getUpdatePercent(progress); +function updateProgressBar(percentage: number, progressBar: ProgressBar) { + progressBar.value = percentage; } diff --git a/src/presentation/electron/Update/Updater.ts b/src/presentation/electron/Update/Updater.ts new file mode 100644 index 00000000..f87644cd --- /dev/null +++ b/src/presentation/electron/Update/Updater.ts @@ -0,0 +1,40 @@ +import { autoUpdater, UpdateInfo } from 'electron-updater'; +import log from 'electron-log'; +import { handleManualUpdateAsync, requiresManualUpdate } from './ManualUpdater'; +import { handleAutoUpdateAsync } from './AutoUpdater'; + +interface IUpdater { + checkForUpdatesAsync(): Promise; +} + +export function setupAutoUpdater(): IUpdater { + autoUpdater.logger = log; + autoUpdater.autoDownload = false; // Checking and downloading are handled separately based on platform/user choice + autoUpdater.on('error', (error: Error) => { + log.error('@error@\n', error); + }); + let isAlreadyHandled = false; + autoUpdater.on('update-available', async (info: UpdateInfo) => { + log.info('@update-available@\n', info); + if (isAlreadyHandled) { + log.info('Available updates is already handled'); + return; + } + isAlreadyHandled = true; + await handleAvailableUpdateAsync(info); + }); + return { + checkForUpdatesAsync: async () => { + // autoUpdater.emit('update-available'); // For testing + await autoUpdater.checkForUpdates(); + }, + }; +} + +async function handleAvailableUpdateAsync(info: UpdateInfo) { + if (requiresManualUpdate()) { + await handleManualUpdateAsync(info); + return; + } + await handleAutoUpdateAsync(); +} diff --git a/src/presentation/electron/Updater.ts b/src/presentation/electron/Updater.ts deleted file mode 100644 index dadbdcd2..00000000 --- a/src/presentation/electron/Updater.ts +++ /dev/null @@ -1,83 +0,0 @@ -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/electron/main.ts b/src/presentation/electron/main.ts index 7a5b3f29..196406a1 100644 --- a/src/presentation/electron/main.ts +++ b/src/presentation/electron/main.ts @@ -9,7 +9,7 @@ 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'; +import { setupAutoUpdater } from './Update/Updater'; const isDevelopment = process.env.NODE_ENV !== 'production'; declare const __static: string; // https://github.com/electron-userland/electron-webpack/issues/172 @@ -28,7 +28,6 @@ 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(1650, 955); @@ -55,15 +54,26 @@ function createWindow() { }); } +let macOsQuit = false; // 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(); + if (process.platform === 'darwin' + && !macOsQuit) { + // On macOS it is common for applications and their menu bar + // to stay active until the user quits explicitly with Cmd + Q + return; } + app.quit(); }); +if (process.platform === 'darwin') { + // On macOS we application quit is stopped if user does not Cmd + Q + // But we still want to be able to use app.quit() and quit the application on menu bar, after updates etc. + app.on('before-quit', () => { + macOsQuit = true; + }); +} + 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. @@ -142,4 +152,3 @@ 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 b18e2300..36d46c31 100644 --- a/vue.config.js +++ b/vue.config.js @@ -1,10 +1,4 @@ -const packageJson = require('./package.json'); - -// Send data to application runtime -process.env.VUE_APP_VERSION = packageJson.version; -process.env.VUE_APP_NAME = packageJson.name; -process.env.VUE_APP_REPOSITORY_URL = packageJson.repository.url; -process.env.VUE_APP_HOMEPAGE_URL = packageJson.homepage; +loadVueAppRuntimeVariables(); module.exports = { chainWebpack: (config) => { @@ -42,9 +36,17 @@ module.exports = { } } } - } + }, } function changeAppEntryPoint(entryPath, config) { config.entry('app').clear().add(entryPath).end(); } + +function loadVueAppRuntimeVariables() { + const packageJson = require('./package.json'); + process.env.VUE_APP_VERSION = packageJson.version; + process.env.VUE_APP_NAME = packageJson.name; + process.env.VUE_APP_REPOSITORY_URL = packageJson.repository.url; + process.env.VUE_APP_HOMEPAGE_URL = packageJson.homepage; +};