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