From 264285d2f7ea0d903e63917934c08558c1ba257c Mon Sep 17 00:00:00 2001 From: jgresham Date: Thu, 18 Apr 2024 06:31:58 -0700 Subject: [PATCH 01/11] use autoUpdater from electron with github feedUrl for dialog control --- package-lock.json | 4 +- src/main/basicUpdater.ts | 134 +++++++++++++++++++++++++++++ src/main/main.ts | 9 +- src/main/updater.ts | 176 +++++++++++++++++++-------------------- 4 files changed, 227 insertions(+), 96 deletions(-) create mode 100644 src/main/basicUpdater.ts diff --git a/package-lock.json b/package-lock.json index 45cf8dc..ca819c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nice-node", - "version": "5.0.0-alpha", + "version": "5.1.2-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nice-node", - "version": "5.0.0-alpha", + "version": "5.1.2-alpha", "license": "MIT", "dependencies": { "@reduxjs/toolkit": "^1.9.3", diff --git a/src/main/basicUpdater.ts b/src/main/basicUpdater.ts new file mode 100644 index 0000000..71929d9 --- /dev/null +++ b/src/main/basicUpdater.ts @@ -0,0 +1,134 @@ +// import sleep from 'await-sleep'; +// import { type BrowserWindow, autoUpdater, dialog, FeedURLOptions } from 'electron'; +import type { BrowserWindow } from 'electron'; +// // const { updateElectronApp } = require('update-electron-app') +import { type IUpdateElectronAppOptions, updateElectronApp, UpdateSourceType } from 'update-electron-app'; +import logger, { autoUpdateLogger } from './logger'; +import log from 'electron-log/main'; +const updateLogger = log.scope('updater'); + +// import { reportEvent } from './events'; +// import i18nMain from './i18nMain'; +// // import logger, { autoUpdateLogger } from './logger'; +// import logger from './logger'; +// import { getSetIsPreReleaseUpdatesEnabled } from './state/settings'; + +// let notifyUserIfNoUpdateAvailable: boolean; + +// const t = i18nMain.getFixedT(null, 'updater'); + +// const intiUpdateHandlers = (browserWindow: BrowserWindow) => { +// autoUpdater.on('error', (error) => { +// logger.error('autoUpdater:::::::::error', error); +// }); + +// autoUpdater.on('checking-for-update', () => { +// logger.info('autoUpdater:::::::::checking-for-update'); +// }); +// autoUpdater.on('update-available', async (info: any) => { +// logger.info('autoUpdater:::::::::update-available: ', info); +// // Quick fix to wait for window load before showing update prompt +// await sleep(5000); +// dialog +// .showMessageBox(browserWindow, { +// type: 'info', +// title: t('UpdateAvailable'), +// message: `${t('UpdateNiceNode')} ${info.version}.`, +// buttons: [t('Yes'), t('No')], +// }) +// .then(async (buttonIndex) => { +// if (buttonIndex.response === 0) { +// console.log('update accepted by user'); +// console.log('starting download'); +// autoUpdater.quitAndInstall(); +// dialog.showMessageBox(browserWindow, { +// type: 'info', +// title: t('UpdateAvailable'), +// message: t('DownloadingUpdate'), +// }); +// } else { +// console.log('update checkbox not checked'); +// } +// }) +// .catch((err) => { +// console.error('error in update available dialog: ', err); +// }); +// }); + +// autoUpdater.on('update-not-available', () => { +// logger.info('autoUpdater:::::::::update-not-available'); +// if (notifyUserIfNoUpdateAvailable) { +// dialog.showMessageBox(browserWindow, { +// type: 'info', +// title: t('NoUpdateAvailable'), +// message: t('NoUpdateAvailable'), +// }); +// notifyUserIfNoUpdateAvailable = false; +// } +// }); + +// autoUpdater.on('update-downloaded', () => { +// logger.info('autoUpdater:::::::::update-downloaded'); +// logger.info('Calling autoUpdater.quitAndInstall()'); +// reportEvent('UpdatedNiceNode'); +// try { +// autoUpdater.quitAndInstall(); +// } catch (err) { +// logger.error('Error in: autoUpdater.quitAndInstall()'); +// logger.error(err); +// dialog.showErrorBox( +// t('ErrorUpdating'), +// t('UnableToInstallUpdate', { +// downloadLink: 'https://www.nicenode.xyz/#download', +// }), +// ); +// // todo: send error details +// reportEvent('ErrorUpdatingNiceNode'); +// } +// }); +// }; + +// export const initialize = (mainWindow: BrowserWindow) => { +// // autoUpdater.logger = autoUpdateLogger; +// // autoUpdater.autoDownload = false; +// // autoUpdater.autoInstallOnAppQuit = false; +// const isPreReleaseUpdatesEnabled = getSetIsPreReleaseUpdatesEnabled(); +// logger.info(`isPreReleaseUpdatesEnabled: ${isPreReleaseUpdatesEnabled}`); +// // const server = 'https://github.com/NiceNode/nice-node/releases/latest' +// // const url = `${server}/update/${process.platform}/${app.getVersion()}` +// // autoUpdater.setFeedURL({ url }); +// // autoUpdater.allowPrerelease = isPreReleaseUpdatesEnabled; +// notifyUserIfNoUpdateAvailable = false; +// intiUpdateHandlers(mainWindow); +// }; + +export const checkForUpdates = (notifyIfNoUpdateAvailable: boolean) => { + logger.info(`updater.checkForUpdates set to: ${notifyIfNoUpdateAvailable}`); + // notifyUserIfNoUpdateAvailable = notifyIfNoUpdateAvailable; + // autoUpdater.checkForUpdates(); +}; + +export const setAllowPrerelease = (isAllowPrerelease: boolean) => { + logger.info(`updater.allowPrerelease set to: ${isAllowPrerelease}`); + // pre-release: not available https://www.electronjs.org/docs/latest/api/auto-updater#event-update-available + // autoUpdater.allowPrerelease = isAllowPrerelease; +}; + +// const feedUrl = `https://update.electronjs.org/NiceNode/test-nice-node-updater/darwin-arm64/5.1.2-alpha` +export const initialize = (mainWindow: BrowserWindow) => { + updateLogger.info('initialize updater'); + + const options: IUpdateElectronAppOptions = { + updateSource: { + type: UpdateSourceType.ElectronPublicUpdateService, + // repo: 'NiceNode/nice-node', + repo: 'NiceNode/test-nice-node-updater', + host: 'https://update.electronjs.org', + }, + updateInterval: '5 minutes', // testing + logger: updateLogger + } + + updateLogger.info('updater options: ', options); + updateElectronApp(options); +} diff --git a/src/main/main.ts b/src/main/main.ts index 3310a3b..bcd5991 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -84,7 +84,7 @@ const RESOURCES_PATH = app.isPackaged : path.join(__dirname, '..', '..', 'assets'); // starting point: .vite/build/main.js const getAssetPath = (...paths: string[]): string => { - logger.log('RESOURCES_PATH: ', RESOURCES_PATH); + logger.info('RESOURCES_PATH: ', RESOURCES_PATH); return path.join(RESOURCES_PATH, ...paths); }; @@ -158,7 +158,12 @@ export const createWindow = async () => { // App auto updates updater.initialize(mainWindow); - // updater.checkForUpdates(false); + // disabled in dev env + if (!isDevelopment) { + updater.checkForUpdates(false); + } else { + logger.info('updater.checkForUpdates() skipped. Disabled in development env'); + } menuBuilder = new MenuBuilder(mainWindow); menuBuilder.buildMenu(); diff --git a/src/main/updater.ts b/src/main/updater.ts index f2be115..60c1df0 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,92 +1,90 @@ -// import sleep from 'await-sleep'; +import sleep from 'await-sleep'; // import { type BrowserWindow, autoUpdater, dialog, FeedURLOptions } from 'electron'; -import type { BrowserWindow } from 'electron'; -// // const { updateElectronApp } = require('update-electron-app') -import { type IUpdateElectronAppOptions, updateElectronApp, UpdateSourceType } from 'update-electron-app'; +import { app, autoUpdater, dialog, type BrowserWindow } from 'electron'; import logger, { autoUpdateLogger } from './logger'; import log from 'electron-log/main'; const updateLogger = log.scope('updater'); -// import { reportEvent } from './events'; -// import i18nMain from './i18nMain'; -// // import logger, { autoUpdateLogger } from './logger'; +import { reportEvent } from './events'; +import i18nMain from './i18nMain'; +// import logger, { autoUpdateLogger } from './logger'; // import logger from './logger'; -// import { getSetIsPreReleaseUpdatesEnabled } from './state/settings'; +import { getSetIsPreReleaseUpdatesEnabled } from './state/settings'; -// let notifyUserIfNoUpdateAvailable: boolean; +let notifyUserIfNoUpdateAvailable: boolean; -// const t = i18nMain.getFixedT(null, 'updater'); +const t = i18nMain.getFixedT(null, 'updater'); -// const intiUpdateHandlers = (browserWindow: BrowserWindow) => { -// autoUpdater.on('error', (error) => { -// logger.error('autoUpdater:::::::::error', error); -// }); +const intiUpdateHandlers = (browserWindow: BrowserWindow) => { + autoUpdater.on('error', (error) => { + logger.error('autoUpdater:::::::::error', error); + }); -// autoUpdater.on('checking-for-update', () => { -// logger.info('autoUpdater:::::::::checking-for-update'); -// }); -// autoUpdater.on('update-available', async (info: any) => { -// logger.info('autoUpdater:::::::::update-available: ', info); -// // Quick fix to wait for window load before showing update prompt -// await sleep(5000); -// dialog -// .showMessageBox(browserWindow, { -// type: 'info', -// title: t('UpdateAvailable'), -// message: `${t('UpdateNiceNode')} ${info.version}.`, -// buttons: [t('Yes'), t('No')], -// }) -// .then(async (buttonIndex) => { -// if (buttonIndex.response === 0) { -// console.log('update accepted by user'); -// console.log('starting download'); -// autoUpdater.quitAndInstall(); -// dialog.showMessageBox(browserWindow, { -// type: 'info', -// title: t('UpdateAvailable'), -// message: t('DownloadingUpdate'), -// }); -// } else { -// console.log('update checkbox not checked'); -// } -// }) -// .catch((err) => { -// console.error('error in update available dialog: ', err); -// }); -// }); + autoUpdater.on('checking-for-update', () => { + logger.info('autoUpdater:::::::::checking-for-update'); + }); + autoUpdater.on('update-available', async (info: any) => { + logger.info('autoUpdater:::::::::update-available: ', info); + // Quick fix to wait for window load before showing update prompt + await sleep(5000); + dialog + .showMessageBox(browserWindow, { + type: 'info', + title: t('UpdateAvailable'), + message: `${t('UpdateNiceNode')} ${info.version}.`, + buttons: [t('Yes'), t('No')], + }) + .then(async (buttonIndex) => { + if (buttonIndex.response === 0) { + console.log('update accepted by user'); + console.log('starting download'); + autoUpdater.quitAndInstall(); + dialog.showMessageBox(browserWindow, { + type: 'info', + title: t('UpdateAvailable'), + message: t('DownloadingUpdate'), + }); + } else { + console.log('update checkbox not checked'); + } + }) + .catch((err) => { + console.error('error in update available dialog: ', err); + }); + }); -// autoUpdater.on('update-not-available', () => { -// logger.info('autoUpdater:::::::::update-not-available'); -// if (notifyUserIfNoUpdateAvailable) { -// dialog.showMessageBox(browserWindow, { -// type: 'info', -// title: t('NoUpdateAvailable'), -// message: t('NoUpdateAvailable'), -// }); -// notifyUserIfNoUpdateAvailable = false; -// } -// }); + autoUpdater.on('update-not-available', () => { + logger.info('autoUpdater:::::::::update-not-available'); + if (notifyUserIfNoUpdateAvailable) { + dialog.showMessageBox(browserWindow, { + type: 'info', + title: t('NoUpdateAvailable'), + message: t('NoUpdateAvailable'), + }); + notifyUserIfNoUpdateAvailable = false; + } + }); -// autoUpdater.on('update-downloaded', () => { -// logger.info('autoUpdater:::::::::update-downloaded'); -// logger.info('Calling autoUpdater.quitAndInstall()'); -// reportEvent('UpdatedNiceNode'); -// try { -// autoUpdater.quitAndInstall(); -// } catch (err) { -// logger.error('Error in: autoUpdater.quitAndInstall()'); -// logger.error(err); -// dialog.showErrorBox( -// t('ErrorUpdating'), -// t('UnableToInstallUpdate', { -// downloadLink: 'https://www.nicenode.xyz/#download', -// }), -// ); -// // todo: send error details -// reportEvent('ErrorUpdatingNiceNode'); -// } -// }); -// }; + autoUpdater.on('update-downloaded', (...args) => { + logger.info('autoUpdater:::::::::update-downloaded args: ', args); + logger.info('Calling autoUpdater.quitAndInstall()'); + reportEvent('UpdatedNiceNode'); + try { + autoUpdater.quitAndInstall(); + } catch (err) { + logger.error('Error in: autoUpdater.quitAndInstall()'); + logger.error(err); + dialog.showErrorBox( + t('ErrorUpdating'), + t('UnableToInstallUpdate', { + downloadLink: 'https://www.nicenode.xyz/#download', + }), + ); + // todo: send error details + reportEvent('ErrorUpdatingNiceNode'); + } + }); +}; // export const initialize = (mainWindow: BrowserWindow) => { // // autoUpdater.logger = autoUpdateLogger; @@ -104,8 +102,8 @@ const updateLogger = log.scope('updater'); export const checkForUpdates = (notifyIfNoUpdateAvailable: boolean) => { logger.info(`updater.checkForUpdates set to: ${notifyIfNoUpdateAvailable}`); - // notifyUserIfNoUpdateAvailable = notifyIfNoUpdateAvailable; - // autoUpdater.checkForUpdates(); + notifyUserIfNoUpdateAvailable = notifyIfNoUpdateAvailable; + autoUpdater.checkForUpdates(); }; export const setAllowPrerelease = (isAllowPrerelease: boolean) => { @@ -116,17 +114,11 @@ export const setAllowPrerelease = (isAllowPrerelease: boolean) => { export const initialize = (mainWindow: BrowserWindow) => { updateLogger.info('initialize updater'); - - const options: IUpdateElectronAppOptions = { - updateSource: { - type: UpdateSourceType.ElectronPublicUpdateService, - repo: 'NiceNode/nice-node', - host: 'https://update.electronjs.org', - }, - updateInterval: '5 minutes', // testing - logger: updateLogger - } - - updateLogger.info('updater options: ', options); - updateElectronApp(options); + const host = 'https://update.electronjs.org'; + const publicRepo = 'NiceNode/test-nice-node-updater'; + const currentAppVersion = app.getVersion(); // ex. 5.1.2-alpha + const feedUrl = `${host}/${publicRepo}/${process.platform}-${process.arch}/${currentAppVersion}` + autoUpdater.setFeedURL({ url: feedUrl }); + notifyUserIfNoUpdateAvailable = false; + intiUpdateHandlers(mainWindow); } From f67378322bf3d0d9509c0a8a536b00160d6fa954 Mon Sep 17 00:00:00 2001 From: jgresham Date: Thu, 18 Apr 2024 06:34:24 -0700 Subject: [PATCH 02/11] bump 5.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ddf39f0..079f024 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nice-node", - "version": "5.1.2-alpha", + "version": "5.2.0-alpha", "description": "Run a node at home, the easy way.", "homepage": "https://nicenode.xyz", "productName": "NiceNode", From ab2e070a901c3e59e2da814b11accf602d167816 Mon Sep 17 00:00:00 2001 From: jgresham Date: Thu, 18 Apr 2024 07:19:32 -0700 Subject: [PATCH 03/11] bump 5.2.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 079f024..39eab4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nice-node", - "version": "5.2.0-alpha", + "version": "5.2.1-alpha", "description": "Run a node at home, the easy way.", "homepage": "https://nicenode.xyz", "productName": "NiceNode", From ef7e54d12a3da83531150ccdece643d0a3704066 Mon Sep 17 00:00:00 2001 From: jgresham Date: Thu, 18 Apr 2024 08:14:43 -0700 Subject: [PATCH 04/11] bump 5.3.0. minor cleanup --- package.json | 2 +- src/main/basicUpdater.ts | 134 --------------------------------------- src/main/updater.ts | 20 +++--- 3 files changed, 10 insertions(+), 146 deletions(-) delete mode 100644 src/main/basicUpdater.ts diff --git a/package.json b/package.json index 39eab4d..3af1983 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nice-node", - "version": "5.2.1-alpha", + "version": "5.3.0-alpha", "description": "Run a node at home, the easy way.", "homepage": "https://nicenode.xyz", "productName": "NiceNode", diff --git a/src/main/basicUpdater.ts b/src/main/basicUpdater.ts deleted file mode 100644 index 71929d9..0000000 --- a/src/main/basicUpdater.ts +++ /dev/null @@ -1,134 +0,0 @@ -// import sleep from 'await-sleep'; -// import { type BrowserWindow, autoUpdater, dialog, FeedURLOptions } from 'electron'; -import type { BrowserWindow } from 'electron'; -// // const { updateElectronApp } = require('update-electron-app') -import { type IUpdateElectronAppOptions, updateElectronApp, UpdateSourceType } from 'update-electron-app'; -import logger, { autoUpdateLogger } from './logger'; -import log from 'electron-log/main'; -const updateLogger = log.scope('updater'); - -// import { reportEvent } from './events'; -// import i18nMain from './i18nMain'; -// // import logger, { autoUpdateLogger } from './logger'; -// import logger from './logger'; -// import { getSetIsPreReleaseUpdatesEnabled } from './state/settings'; - -// let notifyUserIfNoUpdateAvailable: boolean; - -// const t = i18nMain.getFixedT(null, 'updater'); - -// const intiUpdateHandlers = (browserWindow: BrowserWindow) => { -// autoUpdater.on('error', (error) => { -// logger.error('autoUpdater:::::::::error', error); -// }); - -// autoUpdater.on('checking-for-update', () => { -// logger.info('autoUpdater:::::::::checking-for-update'); -// }); -// autoUpdater.on('update-available', async (info: any) => { -// logger.info('autoUpdater:::::::::update-available: ', info); -// // Quick fix to wait for window load before showing update prompt -// await sleep(5000); -// dialog -// .showMessageBox(browserWindow, { -// type: 'info', -// title: t('UpdateAvailable'), -// message: `${t('UpdateNiceNode')} ${info.version}.`, -// buttons: [t('Yes'), t('No')], -// }) -// .then(async (buttonIndex) => { -// if (buttonIndex.response === 0) { -// console.log('update accepted by user'); -// console.log('starting download'); -// autoUpdater.quitAndInstall(); -// dialog.showMessageBox(browserWindow, { -// type: 'info', -// title: t('UpdateAvailable'), -// message: t('DownloadingUpdate'), -// }); -// } else { -// console.log('update checkbox not checked'); -// } -// }) -// .catch((err) => { -// console.error('error in update available dialog: ', err); -// }); -// }); - -// autoUpdater.on('update-not-available', () => { -// logger.info('autoUpdater:::::::::update-not-available'); -// if (notifyUserIfNoUpdateAvailable) { -// dialog.showMessageBox(browserWindow, { -// type: 'info', -// title: t('NoUpdateAvailable'), -// message: t('NoUpdateAvailable'), -// }); -// notifyUserIfNoUpdateAvailable = false; -// } -// }); - -// autoUpdater.on('update-downloaded', () => { -// logger.info('autoUpdater:::::::::update-downloaded'); -// logger.info('Calling autoUpdater.quitAndInstall()'); -// reportEvent('UpdatedNiceNode'); -// try { -// autoUpdater.quitAndInstall(); -// } catch (err) { -// logger.error('Error in: autoUpdater.quitAndInstall()'); -// logger.error(err); -// dialog.showErrorBox( -// t('ErrorUpdating'), -// t('UnableToInstallUpdate', { -// downloadLink: 'https://www.nicenode.xyz/#download', -// }), -// ); -// // todo: send error details -// reportEvent('ErrorUpdatingNiceNode'); -// } -// }); -// }; - -// export const initialize = (mainWindow: BrowserWindow) => { -// // autoUpdater.logger = autoUpdateLogger; -// // autoUpdater.autoDownload = false; -// // autoUpdater.autoInstallOnAppQuit = false; -// const isPreReleaseUpdatesEnabled = getSetIsPreReleaseUpdatesEnabled(); -// logger.info(`isPreReleaseUpdatesEnabled: ${isPreReleaseUpdatesEnabled}`); -// // const server = 'https://github.com/NiceNode/nice-node/releases/latest' -// // const url = `${server}/update/${process.platform}/${app.getVersion()}` -// // autoUpdater.setFeedURL({ url }); -// // autoUpdater.allowPrerelease = isPreReleaseUpdatesEnabled; -// notifyUserIfNoUpdateAvailable = false; -// intiUpdateHandlers(mainWindow); -// }; - -export const checkForUpdates = (notifyIfNoUpdateAvailable: boolean) => { - logger.info(`updater.checkForUpdates set to: ${notifyIfNoUpdateAvailable}`); - // notifyUserIfNoUpdateAvailable = notifyIfNoUpdateAvailable; - // autoUpdater.checkForUpdates(); -}; - -export const setAllowPrerelease = (isAllowPrerelease: boolean) => { - logger.info(`updater.allowPrerelease set to: ${isAllowPrerelease}`); - // pre-release: not available https://www.electronjs.org/docs/latest/api/auto-updater#event-update-available - // autoUpdater.allowPrerelease = isAllowPrerelease; -}; - -// const feedUrl = `https://update.electronjs.org/NiceNode/test-nice-node-updater/darwin-arm64/5.1.2-alpha` -export const initialize = (mainWindow: BrowserWindow) => { - updateLogger.info('initialize updater'); - - const options: IUpdateElectronAppOptions = { - updateSource: { - type: UpdateSourceType.ElectronPublicUpdateService, - // repo: 'NiceNode/nice-node', - repo: 'NiceNode/test-nice-node-updater', - host: 'https://update.electronjs.org', - }, - updateInterval: '5 minutes', // testing - logger: updateLogger - } - - updateLogger.info('updater options: ', options); - updateElectronApp(options); -} diff --git a/src/main/updater.ts b/src/main/updater.ts index 60c1df0..d5963d3 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,21 +1,18 @@ import sleep from 'await-sleep'; -// import { type BrowserWindow, autoUpdater, dialog, FeedURLOptions } from 'electron'; import { app, autoUpdater, dialog, type BrowserWindow } from 'electron'; -import logger, { autoUpdateLogger } from './logger'; -import log from 'electron-log/main'; -const updateLogger = log.scope('updater'); +import { autoUpdateLogger } from './logger'; import { reportEvent } from './events'; import i18nMain from './i18nMain'; -// import logger, { autoUpdateLogger } from './logger'; -// import logger from './logger'; -import { getSetIsPreReleaseUpdatesEnabled } from './state/settings'; +// import { getSetIsPreReleaseUpdatesEnabled } from './state/settings'; let notifyUserIfNoUpdateAvailable: boolean; const t = i18nMain.getFixedT(null, 'updater'); -const intiUpdateHandlers = (browserWindow: BrowserWindow) => { +const logger = autoUpdateLogger; + +const initUpdateHandlers = (browserWindow: BrowserWindow) => { autoUpdater.on('error', (error) => { logger.error('autoUpdater:::::::::error', error); }); @@ -97,7 +94,7 @@ const intiUpdateHandlers = (browserWindow: BrowserWindow) => { // // autoUpdater.setFeedURL({ url }); // // autoUpdater.allowPrerelease = isPreReleaseUpdatesEnabled; // notifyUserIfNoUpdateAvailable = false; -// intiUpdateHandlers(mainWindow); +// initUpdateHandlers(mainWindow); // }; export const checkForUpdates = (notifyIfNoUpdateAvailable: boolean) => { @@ -113,12 +110,13 @@ export const setAllowPrerelease = (isAllowPrerelease: boolean) => { }; export const initialize = (mainWindow: BrowserWindow) => { - updateLogger.info('initialize updater'); + logger.info('initialize updater'); const host = 'https://update.electronjs.org'; const publicRepo = 'NiceNode/test-nice-node-updater'; const currentAppVersion = app.getVersion(); // ex. 5.1.2-alpha const feedUrl = `${host}/${publicRepo}/${process.platform}-${process.arch}/${currentAppVersion}` + logger.info(`electron.autoUpdater feedUrl set to ${feedUrl}`); autoUpdater.setFeedURL({ url: feedUrl }); notifyUserIfNoUpdateAvailable = false; - intiUpdateHandlers(mainWindow); + initUpdateHandlers(mainWindow); } From fb497428970328b1a778b4ef88a9f016d7082986 Mon Sep 17 00:00:00 2001 From: jgresham Date: Thu, 18 Apr 2024 08:16:56 -0700 Subject: [PATCH 05/11] bump 5.4.0-alpha --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3af1983..a670cc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nice-node", - "version": "5.3.0-alpha", + "version": "5.4.0-alpha", "description": "Run a node at home, the easy way.", "homepage": "https://nicenode.xyz", "productName": "NiceNode", From 296802be6b4872e9a3fc42abc95ad932e2752ed9 Mon Sep 17 00:00:00 2001 From: jgresham Date: Thu, 18 Apr 2024 10:05:38 -0700 Subject: [PATCH 06/11] bump 5.4.1-alpha --- README.md | 6 +++++ package.json | 2 +- src/main/main.ts | 4 +++ src/main/updater.ts | 62 +++++++++++++++++++++++++-------------------- 4 files changed, 45 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index dc05e24..aa4071d 100644 --- a/README.md +++ b/README.md @@ -95,3 +95,9 @@ npm run storybook `npx @electron/asar extract app.asar ` can be used to determine the contents of asar `npm run package` followed by `mkdir outAsar` and `npx @electron/asar extract ./out/NiceNode-darwin-arm64/NiceNode.app/Contents/Resources/app.asar outAsar` on macOS + +## Debugging update server (macOS and Windows) +`curl https://update.electronjs.org/NiceNode/test-nice-node-updater/darwin-arm64/5.2.0-alpha` returns if there is an update detected by the update server. The end of the URL is -/. + +Also, NiceNode app auto update logs are in ../NiceNode/logs/autoUpdater*.log. + diff --git a/package.json b/package.json index a670cc1..6a8f833 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nice-node", - "version": "5.4.0-alpha", + "version": "5.4.1-alpha", "description": "Run a node at home, the easy way.", "homepage": "https://nicenode.xyz", "productName": "NiceNode", diff --git a/src/main/main.ts b/src/main/main.ts index bcd5991..d46c415 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -200,6 +200,10 @@ export const fullQuit = () => { app.quit(); }; +export const setFullQuitForNextQuit = (_isNextQuitAFullQuit: boolean) => { + isFullQuit = _isNextQuitAFullQuit; +}; + // Emitted on app.quit() after all windows have been closed app.on('will-quit', (e) => { // Remove dev env check to test background. This is to prevent diff --git a/src/main/updater.ts b/src/main/updater.ts index d5963d3..a6875aa 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -4,6 +4,7 @@ import { autoUpdateLogger } from './logger'; import { reportEvent } from './events'; import i18nMain from './i18nMain'; +import { setFullQuitForNextQuit } from './main'; // import { getSetIsPreReleaseUpdatesEnabled } from './state/settings'; let notifyUserIfNoUpdateAvailable: boolean; @@ -20,21 +21,45 @@ const initUpdateHandlers = (browserWindow: BrowserWindow) => { autoUpdater.on('checking-for-update', () => { logger.info('autoUpdater:::::::::checking-for-update'); }); - autoUpdater.on('update-available', async (info: any) => { - logger.info('autoUpdater:::::::::update-available: ', info); + autoUpdater.on('update-available', async () => { + logger.info('autoUpdater:::::::::update-available: '); + // this is unused now, as the download starts automatically now. We could show the user + // that an update is downloading starting now. // Quick fix to wait for window load before showing update prompt - await sleep(5000); - dialog + // await sleep(5000); + + }); + + autoUpdater.on('update-not-available', () => { + logger.info('autoUpdater:::::::::update-not-available'); + if (notifyUserIfNoUpdateAvailable) { + dialog.showMessageBox(browserWindow, { + type: 'info', + title: t('NoUpdateAvailable'), + message: t('NoUpdateAvailable'), + }); + notifyUserIfNoUpdateAvailable = false; + } + }); + + autoUpdater.on('update-downloaded', (...args) => { + logger.info('autoUpdater:::::::::update-downloaded args: ', args); + logger.info('Calling autoUpdater.quitAndInstall()'); + try { + const newVersion = args.length > 2 ? args[2] : 'latest version'; + dialog .showMessageBox(browserWindow, { type: 'info', title: t('UpdateAvailable'), - message: `${t('UpdateNiceNode')} ${info.version}.`, + message: `${t('UpdateNiceNode')} ${newVersion}.`, buttons: [t('Yes'), t('No')], }) .then(async (buttonIndex) => { if (buttonIndex.response === 0) { - console.log('update accepted by user'); - console.log('starting download'); + logger.info('update accepted by user. quit and install.'); + reportEvent('UpdatedNiceNode'); + // todo: tell main that full quit incoming + setFullQuitForNextQuit(true); autoUpdater.quitAndInstall(); dialog.showMessageBox(browserWindow, { type: 'info', @@ -42,32 +67,13 @@ const initUpdateHandlers = (browserWindow: BrowserWindow) => { message: t('DownloadingUpdate'), }); } else { - console.log('update checkbox not checked'); + logger.info('update denied by user. install will take place on next quit.'); } }) .catch((err) => { console.error('error in update available dialog: ', err); }); - }); - - autoUpdater.on('update-not-available', () => { - logger.info('autoUpdater:::::::::update-not-available'); - if (notifyUserIfNoUpdateAvailable) { - dialog.showMessageBox(browserWindow, { - type: 'info', - title: t('NoUpdateAvailable'), - message: t('NoUpdateAvailable'), - }); - notifyUserIfNoUpdateAvailable = false; - } - }); - - autoUpdater.on('update-downloaded', (...args) => { - logger.info('autoUpdater:::::::::update-downloaded args: ', args); - logger.info('Calling autoUpdater.quitAndInstall()'); - reportEvent('UpdatedNiceNode'); - try { - autoUpdater.quitAndInstall(); + // autoUpdater.quitAndInstall(); } catch (err) { logger.error('Error in: autoUpdater.quitAndInstall()'); logger.error(err); From 2c95c8836e3d3a6c49152c90705bb69e1d9737e0 Mon Sep 17 00:00:00 2001 From: jgresham Date: Thu, 18 Apr 2024 15:34:10 -0700 Subject: [PATCH 07/11] deb updater mvp --- package-lock.json | 10 +- package.json | 3 +- src/main/electron-updater/AppAdapter.ts | 46 + src/main/electron-updater/AppImageUpdater.ts | 115 +++ src/main/electron-updater/AppUpdater.ts | 833 ++++++++++++++++++ src/main/electron-updater/BaseUpdater.ts | 161 ++++ src/main/electron-updater/DebUpdater.ts | 41 + .../DownloadedUpdateHelper.ts | 199 +++++ .../electron-updater/ElectronAppAdapter.ts | 46 + src/main/electron-updater/MacUpdater.ts | 275 ++++++ src/main/electron-updater/NsisUpdater.ts | 209 +++++ src/main/electron-updater/RpmUpdater.ts | 66 ++ .../differentialDownloader/DataSplitter.ts | 233 +++++ .../DifferentialDownloader.ts | 320 +++++++ ...hEmbeddedBlockMapDifferentialDownloader.ts | 37 + .../GenericDifferentialDownloader.ts | 8 + ...ssDifferentialDownloadCallbackTransform.ts | 118 +++ .../downloadPlanBuilder.ts | 135 +++ .../multipleRangeDownloader.ts | 134 +++ .../electron-updater/electronHttpExecutor.ts | 95 ++ src/main/electron-updater/main.ts | 146 +++ src/main/electron-updater/providerFactory.ts | 85 ++ .../providers/BitbucketProvider.ts | 47 + .../providers/GenericProvider.ts | 52 ++ .../providers/GitHubProvider.ts | 225 +++++ .../providers/KeygenProvider.ts | 53 ++ .../providers/PrivateGitHubProvider.ts | 119 +++ .../electron-updater/providers/Provider.ts | 157 ++++ src/main/electron-updater/util.ts | 37 + .../windowsExecutableCodeSignatureVerifier.ts | 132 +++ src/main/nn-auto-updater/custom.ts | 0 src/main/nn-auto-updater/githubReleases.ts | 141 +++ src/main/nn-auto-updater/main.ts | 82 ++ src/main/updater.ts | 5 +- 34 files changed, 4361 insertions(+), 4 deletions(-) create mode 100644 src/main/electron-updater/AppAdapter.ts create mode 100644 src/main/electron-updater/AppImageUpdater.ts create mode 100644 src/main/electron-updater/AppUpdater.ts create mode 100644 src/main/electron-updater/BaseUpdater.ts create mode 100644 src/main/electron-updater/DebUpdater.ts create mode 100644 src/main/electron-updater/DownloadedUpdateHelper.ts create mode 100644 src/main/electron-updater/ElectronAppAdapter.ts create mode 100644 src/main/electron-updater/MacUpdater.ts create mode 100644 src/main/electron-updater/NsisUpdater.ts create mode 100644 src/main/electron-updater/RpmUpdater.ts create mode 100644 src/main/electron-updater/differentialDownloader/DataSplitter.ts create mode 100644 src/main/electron-updater/differentialDownloader/DifferentialDownloader.ts create mode 100644 src/main/electron-updater/differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader.ts create mode 100644 src/main/electron-updater/differentialDownloader/GenericDifferentialDownloader.ts create mode 100644 src/main/electron-updater/differentialDownloader/ProgressDifferentialDownloadCallbackTransform.ts create mode 100644 src/main/electron-updater/differentialDownloader/downloadPlanBuilder.ts create mode 100644 src/main/electron-updater/differentialDownloader/multipleRangeDownloader.ts create mode 100644 src/main/electron-updater/electronHttpExecutor.ts create mode 100644 src/main/electron-updater/main.ts create mode 100644 src/main/electron-updater/providerFactory.ts create mode 100644 src/main/electron-updater/providers/BitbucketProvider.ts create mode 100644 src/main/electron-updater/providers/GenericProvider.ts create mode 100644 src/main/electron-updater/providers/GitHubProvider.ts create mode 100644 src/main/electron-updater/providers/KeygenProvider.ts create mode 100644 src/main/electron-updater/providers/PrivateGitHubProvider.ts create mode 100644 src/main/electron-updater/providers/Provider.ts create mode 100644 src/main/electron-updater/util.ts create mode 100644 src/main/electron-updater/windowsExecutableCodeSignatureVerifier.ts create mode 100644 src/main/nn-auto-updater/custom.ts create mode 100644 src/main/nn-auto-updater/githubReleases.ts create mode 100644 src/main/nn-auto-updater/main.ts diff --git a/package-lock.json b/package-lock.json index ca819c4..6941ca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nice-node", - "version": "5.1.2-alpha", + "version": "5.4.1-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nice-node", - "version": "5.1.2-alpha", + "version": "5.4.1-alpha", "license": "MIT", "dependencies": { "@reduxjs/toolkit": "^1.9.3", @@ -39,6 +39,7 @@ "react-select": "^5.8.0", "systeminformation": "^5.21.24", "throttle-debounce": "^5.0.0", + "tiny-typed-emitter": "^2.1.0", "update-electron-app": "^3.0.0", "uuid": "^9.0.0", "winston": "^3.13.0", @@ -21003,6 +21004,11 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "dev": true }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==" + }, "node_modules/tinybench": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz", diff --git a/package.json b/package.json index 6a8f833..eec0a10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nice-node", - "version": "5.4.1-alpha", + "version": "5.4.0-alpha", "description": "Run a node at home, the easy way.", "homepage": "https://nicenode.xyz", "productName": "NiceNode", @@ -109,6 +109,7 @@ "react-select": "^5.8.0", "systeminformation": "^5.21.24", "throttle-debounce": "^5.0.0", + "tiny-typed-emitter": "^2.1.0", "update-electron-app": "^3.0.0", "uuid": "^9.0.0", "winston": "^3.13.0", diff --git a/src/main/electron-updater/AppAdapter.ts b/src/main/electron-updater/AppAdapter.ts new file mode 100644 index 0000000..69cc817 --- /dev/null +++ b/src/main/electron-updater/AppAdapter.ts @@ -0,0 +1,46 @@ +import * as path from "path" +import { homedir as getHomedir } from "os" + +export interface AppAdapter { + readonly version: string + readonly name: string + + readonly isPackaged: boolean + + /** + * Path to update metadata file. + */ + readonly appUpdateConfigPath: string + + /** + * Path to user data directory. + */ + readonly userDataPath: string + + /** + * Path to cache directory. + */ + readonly baseCachePath: string + + whenReady(): Promise + + relaunch(): void + + quit(): void + + onQuit(handler: (exitCode: number) => void): void +} + +export function getAppCacheDir() { + const homedir = getHomedir() + // https://github.com/electron/electron/issues/1404#issuecomment-194391247 + let result: string + if (process.platform === "win32") { + result = process.env["LOCALAPPDATA"] || path.join(homedir, "AppData", "Local") + } else if (process.platform === "darwin") { + result = path.join(homedir, "Library", "Caches") + } else { + result = process.env["XDG_CACHE_HOME"] || path.join(homedir, ".cache") + } + return result +} diff --git a/src/main/electron-updater/AppImageUpdater.ts b/src/main/electron-updater/AppImageUpdater.ts new file mode 100644 index 0000000..a8cc747 --- /dev/null +++ b/src/main/electron-updater/AppImageUpdater.ts @@ -0,0 +1,115 @@ +import { AllPublishOptions, newError } from "builder-util-runtime" +import { execFileSync } from "child_process" +import { chmod } from "fs-extra" +import { unlinkSync } from "fs" +import * as path from "path" +import { DownloadUpdateOptions } from "./AppUpdater" +import { BaseUpdater, InstallOptions } from "./BaseUpdater" +import { DifferentialDownloaderOptions } from "./differentialDownloader/DifferentialDownloader" +import { FileWithEmbeddedBlockMapDifferentialDownloader } from "./differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader" +import { DOWNLOAD_PROGRESS } from "./main" +import { findFile } from "./providers/Provider" + +export class AppImageUpdater extends BaseUpdater { + constructor(options?: AllPublishOptions | null, app?: any) { + super(options, app) + } + + public isUpdaterActive(): boolean { + if (process.env["APPIMAGE"] == null) { + if (process.env["SNAP"] == null) { + this._logger.warn("APPIMAGE env is not defined, current application is not an AppImage") + } else { + this._logger.info("SNAP env is defined, updater is disabled") + } + return false + } + return super.isUpdaterActive() + } + + /*** @private */ + protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { + const provider = downloadUpdateOptions.updateInfoAndProvider.provider + const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "AppImage", ["rpm", "deb"])! + return this.executeDownload({ + fileExtension: "AppImage", + fileInfo, + downloadUpdateOptions, + task: async (updateFile, downloadOptions) => { + const oldFile = process.env["APPIMAGE"]! + if (oldFile == null) { + throw newError("APPIMAGE env is not defined", "ERR_UPDATER_OLD_FILE_NOT_FOUND") + } + + let isDownloadFull = false + try { + const downloadOptions: DifferentialDownloaderOptions = { + newUrl: fileInfo.url, + oldFile, + logger: this._logger, + newFile: updateFile, + isUseMultipleRangeRequest: provider.isUseMultipleRangeRequest, + requestHeaders: downloadUpdateOptions.requestHeaders, + cancellationToken: downloadUpdateOptions.cancellationToken, + } + + if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { + downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) + } + + await new FileWithEmbeddedBlockMapDifferentialDownloader(fileInfo.info, this.httpExecutor, downloadOptions).download() + } catch (e: any) { + this._logger.error(`Cannot download differentially, fallback to full download: ${e.stack || e}`) + // during test (developer machine mac) we must throw error + isDownloadFull = process.platform === "linux" + } + + if (isDownloadFull) { + await this.httpExecutor.download(fileInfo.url, updateFile, downloadOptions) + } + + await chmod(updateFile, 0o755) + }, + }) + } + + protected doInstall(options: InstallOptions): boolean { + const appImageFile = process.env["APPIMAGE"]! + if (appImageFile == null) { + throw newError("APPIMAGE env is not defined", "ERR_UPDATER_OLD_FILE_NOT_FOUND") + } + + // https://stackoverflow.com/a/1712051/1910191 + unlinkSync(appImageFile) + + let destination: string + const existingBaseName = path.basename(appImageFile) + // https://github.com/electron-userland/electron-builder/issues/2964 + // if no version in existing file name, it means that user wants to preserve current custom name + if (path.basename(options.installerPath) === existingBaseName || !/\d+\.\d+\.\d+/.test(existingBaseName)) { + // no version in the file name, overwrite existing + destination = appImageFile + } else { + destination = path.join(path.dirname(appImageFile), path.basename(options.installerPath)) + } + + execFileSync("mv", ["-f", options.installerPath, destination]) + if (destination !== appImageFile) { + this.emit("appimage-filename-updated", destination) + } + + const env: NodeJS.ProcessEnv = { + ...process.env, + APPIMAGE_SILENT_INSTALL: "true", + } + + if (options.isForceRunAfter) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.spawnLog(destination, [], env) + } else { + env.APPIMAGE_EXIT_AFTER_INSTALL = "true" + execFileSync(destination, [], { env }) + } + return true + } +} diff --git a/src/main/electron-updater/AppUpdater.ts b/src/main/electron-updater/AppUpdater.ts new file mode 100644 index 0000000..0405b0c --- /dev/null +++ b/src/main/electron-updater/AppUpdater.ts @@ -0,0 +1,833 @@ +import { + AllPublishOptions, + asArray, + CancellationToken, + newError, + PublishConfiguration, + UpdateInfo, + UUID, + DownloadOptions, + CancellationError, + ProgressInfo, + BlockMap, +} from "builder-util-runtime" +import { randomBytes } from "crypto" +import { release } from "os" +import { EventEmitter } from "events" +import { mkdir, outputFile, readFile, rename, unlink } from "fs-extra" +import { OutgoingHttpHeaders } from "http" +import { load } from "js-yaml" +import { Lazy } from "lazy-val" +import * as path from "path" +import { eq as isVersionsEqual, gt as isVersionGreaterThan, lt as isVersionLessThan, parse as parseVersion, prerelease as getVersionPreleaseComponents, SemVer } from "semver" +import { AppAdapter } from "./AppAdapter" +import { createTempUpdateFile, DownloadedUpdateHelper } from "./DownloadedUpdateHelper" +import { ElectronAppAdapter } from "./ElectronAppAdapter" +import { ElectronHttpExecutor, getNetSession, LoginCallback } from "./electronHttpExecutor" +import { GenericProvider } from "./providers/GenericProvider" +import { DOWNLOAD_PROGRESS, Logger, Provider, ResolvedUpdateFileInfo, UPDATE_DOWNLOADED, UpdateCheckResult, UpdateDownloadedEvent, UpdaterSignal } from "./main" +import { createClient, isUrlProbablySupportMultiRangeRequests } from "./providerFactory" +import { ProviderPlatform } from "./providers/Provider" +import type { TypedEmitter } from "tiny-typed-emitter" +import Session = Electron.Session +import { AuthInfo } from "electron" +import { gunzipSync } from "zlib" +import { blockmapFiles } from "./util" +import { DifferentialDownloaderOptions } from "./differentialDownloader/DifferentialDownloader" +import { GenericDifferentialDownloader } from "./differentialDownloader/GenericDifferentialDownloader" + +export type AppUpdaterEvents = { + error: (error: Error, message?: string) => void + login: (info: AuthInfo, callback: LoginCallback) => void + "checking-for-update": () => void + "update-not-available": (info: UpdateInfo) => void + "update-available": (info: UpdateInfo) => void + "update-downloaded": (event: UpdateDownloadedEvent) => void + "download-progress": (info: ProgressInfo) => void + "update-cancelled": (info: UpdateInfo) => void + "appimage-filename-updated": (path: string) => void +} + +export abstract class AppUpdater extends (EventEmitter as new () => TypedEmitter) { + /** + * Whether to automatically download an update when it is found. + */ + autoDownload = true + + /** + * Whether to automatically install a downloaded update on app quit (if `quitAndInstall` was not called before). + */ + autoInstallOnAppQuit = true + + /** + * *windows-only* Whether to run the app after finish install when run the installer NOT in silent mode. + * @default true + */ + autoRunAppAfterInstall = true + + /** + * *GitHub provider only.* Whether to allow update to pre-release versions. Defaults to `true` if application version contains prerelease components (e.g. `0.12.1-alpha.1`, here `alpha` is a prerelease component), otherwise `false`. + * + * If `true`, downgrade will be allowed (`allowDowngrade` will be set to `true`). + */ + allowPrerelease = false + + /** + * *GitHub provider only.* Get all release notes (from current version to latest), not just the latest. + * @default false + */ + fullChangelog = false + + /** + * Whether to allow version downgrade (when a user from the beta channel wants to go back to the stable channel). + * + * Taken in account only if channel differs (pre-release version component in terms of semantic versioning). + * + * @default false + */ + allowDowngrade = false + + /** + * Web installer files might not have signature verification, this switch prevents to load them unless it is needed. + * + * Currently false to prevent breaking the current API, but it should be changed to default true at some point that + * breaking changes are allowed. + * + * @default false + */ + disableWebInstaller = false + + /** + * *NSIS only* Disable differential downloads and always perform full download of installer. + * + * @default false + */ + disableDifferentialDownload = false + + /** + * Allows developer to force the updater to work in "dev" mode, looking for "dev-app-update.yml" instead of "app-update.yml" + * Dev: `path.join(this.app.getAppPath(), "dev-app-update.yml")` + * Prod: `path.join(process.resourcesPath!, "app-update.yml")` + * + * @default false + */ + forceDevUpdateConfig = false + + /** + * The current application version. + */ + readonly currentVersion: SemVer + + private _channel: string | null = null + + protected downloadedUpdateHelper: DownloadedUpdateHelper | null = null + + /** + * Get the update channel. Not applicable for GitHub. Doesn't return `channel` from the update configuration, only if was previously set. + */ + get channel(): string | null { + return this._channel + } + + /** + * Set the update channel. Not applicable for GitHub. Overrides `channel` in the update configuration. + * + * `allowDowngrade` will be automatically set to `true`. If this behavior is not suitable for you, simple set `allowDowngrade` explicitly after. + */ + set channel(value: string | null) { + if (this._channel != null) { + // noinspection SuspiciousTypeOfGuard + if (typeof value !== "string") { + throw newError(`Channel must be a string, but got: ${value}`, "ERR_UPDATER_INVALID_CHANNEL") + } else if (value.length === 0) { + throw newError(`Channel must be not an empty string`, "ERR_UPDATER_INVALID_CHANNEL") + } + } + + this._channel = value + this.allowDowngrade = true + } + + /** + * The request headers. + */ + requestHeaders: OutgoingHttpHeaders | null = null + + /** + * Shortcut for explicitly adding auth tokens to request headers + */ + addAuthHeader(token: string) { + this.requestHeaders = Object.assign({}, this.requestHeaders, { + authorization: token, + }) + } + + protected _logger: Logger = console + + // noinspection JSMethodCanBeStatic,JSUnusedGlobalSymbols + get netSession(): Session { + return getNetSession() + } + + /** + * The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`. + * Set it to `null` if you would like to disable a logging feature. + */ + get logger(): Logger | null { + return this._logger + } + + set logger(value: Logger | null) { + this._logger = value == null ? new NoOpLogger() : value + } + + // noinspection JSUnusedGlobalSymbols + /** + * For type safety you can use signals, e.g. `autoUpdater.signals.updateDownloaded(() => {})` instead of `autoUpdater.on('update-available', () => {})` + */ + readonly signals = new UpdaterSignal(this) + + private _appUpdateConfigPath: string | null = null + + // noinspection JSUnusedGlobalSymbols + /** + * test only + * @private + */ + set updateConfigPath(value: string | null) { + this.clientPromise = null + this._appUpdateConfigPath = value + this.configOnDisk = new Lazy(() => this.loadUpdateConfig()) + } + + private clientPromise: Promise> | null = null + + protected readonly stagingUserIdPromise = new Lazy(() => this.getOrCreateStagingUserId()) + + // public, allow to read old config for anyone + /** @internal */ + configOnDisk = new Lazy(() => this.loadUpdateConfig()) + + private checkForUpdatesPromise: Promise | null = null + + protected readonly app: AppAdapter + + protected updateInfoAndProvider: UpdateInfoAndProvider | null = null + + /** @internal */ + readonly httpExecutor: ElectronHttpExecutor + + protected constructor(options: AllPublishOptions | null | undefined, app?: AppAdapter) { + super() + + this.on("error", (error: Error) => { + this._logger.error(`Error: ${error.stack || error.message}`) + }) + + if (app == null) { + this.app = new ElectronAppAdapter() + this.httpExecutor = new ElectronHttpExecutor((authInfo, callback) => this.emit("login", authInfo, callback)) + } else { + this.app = app + this.httpExecutor = null as any + } + + const currentVersionString = this.app.version + const currentVersion = parseVersion(currentVersionString) + if (currentVersion == null) { + throw newError(`App version is not a valid semver version: "${currentVersionString}"`, "ERR_UPDATER_INVALID_VERSION") + } + this.currentVersion = currentVersion + this.allowPrerelease = hasPrereleaseComponents(currentVersion) + + if (options != null) { + this.setFeedURL(options) + + if (typeof options !== "string" && options.requestHeaders) { + this.requestHeaders = options.requestHeaders + } + } + } + + //noinspection JSMethodCanBeStatic,JSUnusedGlobalSymbols + getFeedURL(): string | null | undefined { + return "Deprecated. Do not use it." + } + + /** + * Configure update provider. If value is `string`, [GenericServerOptions](/configuration/publish#genericserveroptions) will be set with value as `url`. + * @param options If you want to override configuration in the `app-update.yml`. + */ + setFeedURL(options: PublishConfiguration | AllPublishOptions | string) { + const runtimeOptions = this.createProviderRuntimeOptions() + // https://github.com/electron-userland/electron-builder/issues/1105 + let provider: Provider + if (typeof options === "string") { + provider = new GenericProvider({ provider: "generic", url: options }, this, { + ...runtimeOptions, + isUseMultipleRangeRequest: isUrlProbablySupportMultiRangeRequests(options), + }) + } else { + provider = createClient(options, this, runtimeOptions) + } + this.clientPromise = Promise.resolve(provider) + } + + /** + * Asks the server whether there is an update. + */ + checkForUpdates(): Promise { + if (!this.isUpdaterActive()) { + return Promise.resolve(null) + } + + let checkForUpdatesPromise = this.checkForUpdatesPromise + if (checkForUpdatesPromise != null) { + this._logger.info("Checking for update (already in progress)") + return checkForUpdatesPromise + } + + const nullizePromise = () => (this.checkForUpdatesPromise = null) + + this._logger.info("Checking for update") + checkForUpdatesPromise = this.doCheckForUpdates() + .then(it => { + nullizePromise() + return it + }) + .catch((e: any) => { + nullizePromise() + this.emit("error", e, `Cannot check for updates: ${(e.stack || e).toString()}`) + throw e + }) + + this.checkForUpdatesPromise = checkForUpdatesPromise + return checkForUpdatesPromise + } + + public isUpdaterActive(): boolean { + const isEnabled = this.app.isPackaged || this.forceDevUpdateConfig + if (!isEnabled) { + this._logger.info("Skip checkForUpdates because application is not packed and dev update config is not forced") + return false + } + return true + } + + // noinspection JSUnusedGlobalSymbols + checkForUpdatesAndNotify(downloadNotification?: DownloadNotification): Promise { + return this.checkForUpdates().then(it => { + if (!it?.downloadPromise) { + if (this._logger.debug != null) { + this._logger.debug("checkForUpdatesAndNotify called, downloadPromise is null") + } + return it + } + + void it.downloadPromise.then(() => { + const notificationContent = AppUpdater.formatDownloadNotification(it.updateInfo.version, this.app.name, downloadNotification) + new (require("electron").Notification)(notificationContent).show() + }) + + return it + }) + } + + private static formatDownloadNotification(version: string, appName: string, downloadNotification?: DownloadNotification): DownloadNotification { + if (downloadNotification == null) { + downloadNotification = { + title: "A new update is ready to install", + body: `{appName} version {version} has been downloaded and will be automatically installed on exit`, + } + } + downloadNotification = { + title: downloadNotification.title.replace("{appName}", appName).replace("{version}", version), + body: downloadNotification.body.replace("{appName}", appName).replace("{version}", version), + } + return downloadNotification + } + + private async isStagingMatch(updateInfo: UpdateInfo): Promise { + const rawStagingPercentage = updateInfo.stagingPercentage + let stagingPercentage = rawStagingPercentage + if (stagingPercentage == null) { + return true + } + + stagingPercentage = parseInt(stagingPercentage as any, 10) + if (isNaN(stagingPercentage)) { + this._logger.warn(`Staging percentage is NaN: ${rawStagingPercentage}`) + return true + } + + // convert from user 0-100 to internal 0-1 + stagingPercentage = stagingPercentage / 100 + + const stagingUserId = await this.stagingUserIdPromise.value + const val = UUID.parse(stagingUserId).readUInt32BE(12) + const percentage = val / 0xffffffff + this._logger.info(`Staging percentage: ${stagingPercentage}, percentage: ${percentage}, user id: ${stagingUserId}`) + return percentage < stagingPercentage + } + + private computeFinalHeaders(headers: OutgoingHttpHeaders) { + if (this.requestHeaders != null) { + Object.assign(headers, this.requestHeaders) + } + return headers + } + + private async isUpdateAvailable(updateInfo: UpdateInfo): Promise { + const latestVersion = parseVersion(updateInfo.version) + if (latestVersion == null) { + throw newError( + `This file could not be downloaded, or the latest version (from update server) does not have a valid semver version: "${updateInfo.version}"`, + "ERR_UPDATER_INVALID_VERSION" + ) + } + + const currentVersion = this.currentVersion + if (isVersionsEqual(latestVersion, currentVersion)) { + return false + } + + const minimumSystemVersion = updateInfo?.minimumSystemVersion + const currentOSVersion = release() + if (minimumSystemVersion) { + try { + if (isVersionLessThan(currentOSVersion, minimumSystemVersion)) { + this._logger.info(`Current OS version ${currentOSVersion} is less than the minimum OS version required ${minimumSystemVersion} for version ${currentOSVersion}`) + return false + } + } catch (e: any) { + this._logger.warn(`Failed to compare current OS version(${currentOSVersion}) with minimum OS version(${minimumSystemVersion}): ${(e.message || e).toString()}`) + } + } + + const isStagingMatch = await this.isStagingMatch(updateInfo) + if (!isStagingMatch) { + return false + } + + // https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405033227 + // https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405030797 + const isLatestVersionNewer = isVersionGreaterThan(latestVersion, currentVersion) + const isLatestVersionOlder = isVersionLessThan(latestVersion, currentVersion) + + if (isLatestVersionNewer) { + return true + } + return this.allowDowngrade && isLatestVersionOlder + } + + protected async getUpdateInfoAndProvider(): Promise { + await this.app.whenReady() + + if (this.clientPromise == null) { + this.clientPromise = this.configOnDisk.value.then(it => createClient(it, this, this.createProviderRuntimeOptions())) + } + + const client = await this.clientPromise + const stagingUserId = await this.stagingUserIdPromise.value + client.setRequestHeaders(this.computeFinalHeaders({ "x-user-staging-id": stagingUserId })) + return { + info: await client.getLatestVersion(), + provider: client, + } + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + private createProviderRuntimeOptions() { + return { + isUseMultipleRangeRequest: true, + platform: this._testOnlyOptions == null ? (process.platform as ProviderPlatform) : this._testOnlyOptions.platform, + executor: this.httpExecutor, + } + } + + private async doCheckForUpdates(): Promise { + this.emit("checking-for-update") + + const result = await this.getUpdateInfoAndProvider() + const updateInfo = result.info + if (!(await this.isUpdateAvailable(updateInfo))) { + this._logger.info( + `Update for version ${this.currentVersion.format()} is not available (latest version: ${updateInfo.version}, downgrade is ${this.allowDowngrade ? "allowed" : "disallowed"}).` + ) + this.emit("update-not-available", updateInfo) + return { + versionInfo: updateInfo, + updateInfo, + } + } + + this.updateInfoAndProvider = result + this.onUpdateAvailable(updateInfo) + + const cancellationToken = new CancellationToken() + //noinspection ES6MissingAwait + return { + versionInfo: updateInfo, + updateInfo, + cancellationToken, + downloadPromise: this.autoDownload ? this.downloadUpdate(cancellationToken) : null, + } + } + + protected onUpdateAvailable(updateInfo: UpdateInfo): void { + this._logger.info( + `Found version ${updateInfo.version} (url: ${asArray(updateInfo.files) + .map(it => it.url) + .join(", ")})` + ) + this.emit("update-available", updateInfo) + } + + /** + * Start downloading update manually. You can use this method if `autoDownload` option is set to `false`. + * @returns {Promise>} Paths to downloaded files. + */ + downloadUpdate(cancellationToken: CancellationToken = new CancellationToken()): Promise> { + const updateInfoAndProvider = this.updateInfoAndProvider + if (updateInfoAndProvider == null) { + const error = new Error("Please check update first") + this.dispatchError(error) + return Promise.reject(error) + } + + this._logger.info( + `Downloading update from ${asArray(updateInfoAndProvider.info.files) + .map(it => it.url) + .join(", ")}` + ) + const errorHandler = (e: Error): Error => { + // https://github.com/electron-userland/electron-builder/issues/1150#issuecomment-436891159 + if (!(e instanceof CancellationError)) { + try { + this.dispatchError(e) + } catch (nestedError: any) { + this._logger.warn(`Cannot dispatch error event: ${nestedError.stack || nestedError}`) + } + } + + return e + } + + try { + return this.doDownloadUpdate({ + updateInfoAndProvider, + requestHeaders: this.computeRequestHeaders(updateInfoAndProvider.provider), + cancellationToken, + disableWebInstaller: this.disableWebInstaller, + disableDifferentialDownload: this.disableDifferentialDownload, + }).catch((e: any) => { + throw errorHandler(e) + }) + } catch (e: any) { + return Promise.reject(errorHandler(e)) + } + } + + protected dispatchError(e: Error): void { + this.emit("error", e, (e.stack || e).toString()) + } + + protected dispatchUpdateDownloaded(event: UpdateDownloadedEvent): void { + this.emit(UPDATE_DOWNLOADED, event) + } + + protected abstract doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> + + /** + * Restarts the app and installs the update after it has been downloaded. + * It should only be called after `update-downloaded` has been emitted. + * + * **Note:** `autoUpdater.quitAndInstall()` will close all application windows first and only emit `before-quit` event on `app` after that. + * This is different from the normal quit event sequence. + * + * @param isSilent *windows-only* Runs the installer in silent mode. Defaults to `false`. + * @param isForceRunAfter Run the app after finish even on silent install. Not applicable for macOS. + * Ignored if `isSilent` is set to `false`(In this case you can still set `autoRunAppAfterInstall` to `false` to prevent run the app after finish). + */ + abstract quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void + + private async loadUpdateConfig(): Promise { + if (this._appUpdateConfigPath == null) { + this._appUpdateConfigPath = this.app.appUpdateConfigPath + } + return load(await readFile(this._appUpdateConfigPath, "utf-8")) + } + + private computeRequestHeaders(provider: Provider): OutgoingHttpHeaders { + const fileExtraDownloadHeaders = provider.fileExtraDownloadHeaders + if (fileExtraDownloadHeaders != null) { + const requestHeaders = this.requestHeaders + return requestHeaders == null + ? fileExtraDownloadHeaders + : { + ...fileExtraDownloadHeaders, + ...requestHeaders, + } + } + return this.computeFinalHeaders({ accept: "*/*" }) + } + + private async getOrCreateStagingUserId(): Promise { + const file = path.join(this.app.userDataPath, ".updaterId") + try { + const id = await readFile(file, "utf-8") + if (UUID.check(id)) { + return id + } else { + this._logger.warn(`Staging user id file exists, but content was invalid: ${id}`) + } + } catch (e: any) { + if (e.code !== "ENOENT") { + this._logger.warn(`Couldn't read staging user ID, creating a blank one: ${e}`) + } + } + + const id = UUID.v5(randomBytes(4096), UUID.OID) + this._logger.info(`Generated new staging user ID: ${id}`) + try { + await outputFile(file, id) + } catch (e: any) { + this._logger.warn(`Couldn't write out staging user ID: ${e}`) + } + return id + } + + /** @internal */ + get isAddNoCacheQuery(): boolean { + const headers = this.requestHeaders + // https://github.com/electron-userland/electron-builder/issues/3021 + if (headers == null) { + return true + } + + for (const headerName of Object.keys(headers)) { + const s = headerName.toLowerCase() + if (s === "authorization" || s === "private-token") { + return false + } + } + return true + } + + /** + * @private + * @internal + */ + _testOnlyOptions: TestOnlyUpdaterOptions | null = null + + private async getOrCreateDownloadHelper(): Promise { + let result = this.downloadedUpdateHelper + if (result == null) { + const dirName = (await this.configOnDisk.value).updaterCacheDirName + const logger = this._logger + if (dirName == null) { + logger.error("updaterCacheDirName is not specified in app-update.yml Was app build using at least electron-builder 20.34.0?") + } + const cacheDir = path.join(this.app.baseCachePath, dirName || this.app.name) + if (logger.debug != null) { + logger.debug(`updater cache dir: ${cacheDir}`) + } + + result = new DownloadedUpdateHelper(cacheDir) + this.downloadedUpdateHelper = result + } + return result + } + + protected async executeDownload(taskOptions: DownloadExecutorTask): Promise> { + const fileInfo = taskOptions.fileInfo + const downloadOptions: DownloadOptions = { + headers: taskOptions.downloadUpdateOptions.requestHeaders, + cancellationToken: taskOptions.downloadUpdateOptions.cancellationToken, + sha2: (fileInfo.info as any).sha2, + sha512: fileInfo.info.sha512, + } + + if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { + downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) + } + + const updateInfo = taskOptions.downloadUpdateOptions.updateInfoAndProvider.info + const version = updateInfo.version + const packageInfo = fileInfo.packageInfo + + function getCacheUpdateFileName(): string { + // NodeJS URL doesn't decode automatically + const urlPath = decodeURIComponent(taskOptions.fileInfo.url.pathname) + if (urlPath.endsWith(`.${taskOptions.fileExtension}`)) { + return path.basename(urlPath) + } else { + // url like /latest, generate name + return taskOptions.fileInfo.info.url + } + } + + const downloadedUpdateHelper = await this.getOrCreateDownloadHelper() + const cacheDir = downloadedUpdateHelper.cacheDirForPendingUpdate + await mkdir(cacheDir, { recursive: true }) + const updateFileName = getCacheUpdateFileName() + let updateFile = path.join(cacheDir, updateFileName) + const packageFile = packageInfo == null ? null : path.join(cacheDir, `package-${version}${path.extname(packageInfo.path) || ".7z"}`) + + const done = async (isSaveCache: boolean) => { + await downloadedUpdateHelper.setDownloadedFile(updateFile, packageFile, updateInfo, fileInfo, updateFileName, isSaveCache) + await taskOptions.done!({ + ...updateInfo, + downloadedFile: updateFile, + }) + return packageFile == null ? [updateFile] : [updateFile, packageFile] + } + + const log = this._logger + const cachedUpdateFile = await downloadedUpdateHelper.validateDownloadedPath(updateFile, updateInfo, fileInfo, log) + if (cachedUpdateFile != null) { + updateFile = cachedUpdateFile + return await done(false) + } + + const removeFileIfAny = async () => { + await downloadedUpdateHelper.clear().catch(() => { + // ignore + }) + return await unlink(updateFile).catch(() => { + // ignore + }) + } + + const tempUpdateFile = await createTempUpdateFile(`temp-${updateFileName}`, cacheDir, log) + try { + await taskOptions.task(tempUpdateFile, downloadOptions, packageFile, removeFileIfAny) + await rename(tempUpdateFile, updateFile) + } catch (e: any) { + await removeFileIfAny() + + if (e instanceof CancellationError) { + log.info("cancelled") + this.emit("update-cancelled", updateInfo) + } + throw e + } + + log.info(`New version ${version} has been downloaded to ${updateFile}`) + return await done(true) + } + protected async differentialDownloadInstaller( + fileInfo: ResolvedUpdateFileInfo, + downloadUpdateOptions: DownloadUpdateOptions, + installerPath: string, + provider: Provider, + oldInstallerFileName: string + ): Promise { + try { + if (this._testOnlyOptions != null && !this._testOnlyOptions.isUseDifferentialDownload) { + return true + } + const blockmapFileUrls = blockmapFiles(fileInfo.url, this.app.version, downloadUpdateOptions.updateInfoAndProvider.info.version) + this._logger.info(`Download block maps (old: "${blockmapFileUrls[0]}", new: ${blockmapFileUrls[1]})`) + + const downloadBlockMap = async (url: URL): Promise => { + const data = await this.httpExecutor.downloadToBuffer(url, { + headers: downloadUpdateOptions.requestHeaders, + cancellationToken: downloadUpdateOptions.cancellationToken, + }) + + if (data == null || data.length === 0) { + throw new Error(`Blockmap "${url.href}" is empty`) + } + + try { + return JSON.parse(gunzipSync(data).toString()) + } catch (e: any) { + throw new Error(`Cannot parse blockmap "${url.href}", error: ${e}`) + } + } + + const downloadOptions: DifferentialDownloaderOptions = { + newUrl: fileInfo.url, + oldFile: path.join(this.downloadedUpdateHelper!.cacheDir, oldInstallerFileName), + logger: this._logger, + newFile: installerPath, + isUseMultipleRangeRequest: provider.isUseMultipleRangeRequest, + requestHeaders: downloadUpdateOptions.requestHeaders, + cancellationToken: downloadUpdateOptions.cancellationToken, + } + + if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { + downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) + } + + const blockMapDataList = await Promise.all(blockmapFileUrls.map(u => downloadBlockMap(u))) + await new GenericDifferentialDownloader(fileInfo.info, this.httpExecutor, downloadOptions).download(blockMapDataList[0], blockMapDataList[1]) + return false + } catch (e: any) { + this._logger.error(`Cannot download differentially, fallback to full download: ${e.stack || e}`) + if (this._testOnlyOptions != null) { + // test mode + throw e + } + return true + } + } +} + +export interface DownloadUpdateOptions { + readonly updateInfoAndProvider: UpdateInfoAndProvider + readonly requestHeaders: OutgoingHttpHeaders + readonly cancellationToken: CancellationToken + readonly disableWebInstaller?: boolean + readonly disableDifferentialDownload?: boolean +} + +function hasPrereleaseComponents(version: SemVer) { + const versionPrereleaseComponent = getVersionPreleaseComponents(version) + return versionPrereleaseComponent != null && versionPrereleaseComponent.length > 0 +} + +/** @private */ +export class NoOpLogger implements Logger { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + info(message?: any) { + // ignore + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + warn(message?: any) { + // ignore + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + error(message?: any) { + // ignore + } +} + +export interface UpdateInfoAndProvider { + info: UpdateInfo + provider: Provider +} + +export interface DownloadExecutorTask { + readonly fileExtension: string + readonly fileInfo: ResolvedUpdateFileInfo + readonly downloadUpdateOptions: DownloadUpdateOptions + readonly task: (destinationFile: string, downloadOptions: DownloadOptions, packageFile: string | null, removeTempDirIfAny: () => Promise) => Promise + + readonly done?: (event: UpdateDownloadedEvent) => Promise +} + +export interface DownloadNotification { + body: string + title: string +} + +/** @private */ +export interface TestOnlyUpdaterOptions { + platform: ProviderPlatform + + isUseDifferentialDownload?: boolean +} diff --git a/src/main/electron-updater/BaseUpdater.ts b/src/main/electron-updater/BaseUpdater.ts new file mode 100644 index 0000000..4851435 --- /dev/null +++ b/src/main/electron-updater/BaseUpdater.ts @@ -0,0 +1,161 @@ +import { AllPublishOptions } from "builder-util-runtime" +import { spawn, SpawnOptions, spawnSync, StdioOptions } from "child_process" +import { AppAdapter } from "./AppAdapter" +import { AppUpdater, DownloadExecutorTask } from "./AppUpdater" + +export abstract class BaseUpdater extends AppUpdater { + protected quitAndInstallCalled = false + private quitHandlerAdded = false + + protected constructor(options?: AllPublishOptions | null, app?: AppAdapter) { + super(options, app) + } + + quitAndInstall(isSilent = false, isForceRunAfter = false): void { + this._logger.info(`Install on explicit quitAndInstall`) + // If NOT in silent mode use `autoRunAppAfterInstall` to determine whether to force run the app + const isInstalled = this.install(isSilent, isSilent ? isForceRunAfter : this.autoRunAppAfterInstall) + if (isInstalled) { + setImmediate(() => { + // this event is normally emitted when calling quitAndInstall, this emulates that + require("electron").autoUpdater.emit("before-quit-for-update") + this.app.quit() + }) + } else { + this.quitAndInstallCalled = false + } + } + + protected executeDownload(taskOptions: DownloadExecutorTask): Promise> { + return super.executeDownload({ + ...taskOptions, + done: event => { + this.dispatchUpdateDownloaded(event) + this.addQuitHandler() + return Promise.resolve() + }, + }) + } + + // must be sync + protected abstract doInstall(options: InstallOptions): boolean + + // must be sync (because quit even handler is not async) + install(isSilent = false, isForceRunAfter = false): boolean { + if (this.quitAndInstallCalled) { + this._logger.warn("install call ignored: quitAndInstallCalled is set to true") + return false + } + + const downloadedUpdateHelper = this.downloadedUpdateHelper + const installerPath = downloadedUpdateHelper == null ? null : downloadedUpdateHelper.file + const downloadedFileInfo = downloadedUpdateHelper == null ? null : downloadedUpdateHelper.downloadedFileInfo + if (installerPath == null || downloadedFileInfo == null) { + this.dispatchError(new Error("No valid update available, can't quit and install")) + return false + } + + // prevent calling several times + this.quitAndInstallCalled = true + + try { + this._logger.info(`Install: isSilent: ${isSilent}, isForceRunAfter: ${isForceRunAfter}`) + return this.doInstall({ + installerPath, + isSilent, + isForceRunAfter, + isAdminRightsRequired: downloadedFileInfo.isAdminRightsRequired, + }) + } catch (e: any) { + this.dispatchError(e) + return false + } + } + + protected addQuitHandler(): void { + if (this.quitHandlerAdded || !this.autoInstallOnAppQuit) { + return + } + + this.quitHandlerAdded = true + + this.app.onQuit(exitCode => { + if (this.quitAndInstallCalled) { + this._logger.info("Update installer has already been triggered. Quitting application.") + return + } + + if (!this.autoInstallOnAppQuit) { + this._logger.info("Update will not be installed on quit because autoInstallOnAppQuit is set to false.") + return + } + + if (exitCode !== 0) { + this._logger.info(`Update will be not installed on quit because application is quitting with exit code ${exitCode}`) + return + } + + this._logger.info("Auto install update on quit") + this.install(true, false) + }) + } + + protected wrapSudo() { + const { name } = this.app + const installComment = `"${name} would like to update"` + const sudo = this.spawnSyncLog("which gksudo || which kdesudo || which pkexec || which beesu") + const command = [sudo] + if (/kdesudo/i.test(sudo)) { + command.push("--comment", installComment) + command.push("-c") + } else if (/gksudo/i.test(sudo)) { + command.push("--message", installComment) + } else if (/pkexec/i.test(sudo)) { + command.push("--disable-internal-agent") + } + return command.join(" ") + } + + protected spawnSyncLog(cmd: string, args: string[] = [], env = {}): string { + this._logger.info(`Executing: ${cmd} with args: ${args}`) + const response = spawnSync(cmd, args, { + env: { ...process.env, ...env }, + encoding: "utf-8", + shell: true, + }) + return response.stdout.trim() + } + + /** + * This handles both node 8 and node 10 way of emitting error when spawning a process + * - node 8: Throws the error + * - node 10: Emit the error(Need to listen with on) + */ + // https://github.com/electron-userland/electron-builder/issues/1129 + // Node 8 sends errors: https://nodejs.org/dist/latest-v8.x/docs/api/errors.html#errors_common_system_errors + protected async spawnLog(cmd: string, args: string[] = [], env: any = undefined, stdio: StdioOptions = "ignore"): Promise { + this._logger.info(`Executing: ${cmd} with args: ${args}`) + return new Promise((resolve, reject) => { + try { + const params: SpawnOptions = { stdio, env, detached: true } + const p = spawn(cmd, args, params) + p.on("error", error => { + reject(error) + }) + p.unref() + if (p.pid !== undefined) { + resolve(true) + } + } catch (error) { + reject(error) + } + }) + } +} + +export interface InstallOptions { + readonly installerPath: string + readonly isSilent: boolean + readonly isForceRunAfter: boolean + readonly isAdminRightsRequired: boolean +} diff --git a/src/main/electron-updater/DebUpdater.ts b/src/main/electron-updater/DebUpdater.ts new file mode 100644 index 0000000..19705a7 --- /dev/null +++ b/src/main/electron-updater/DebUpdater.ts @@ -0,0 +1,41 @@ +import { AllPublishOptions } from "builder-util-runtime" +import { AppAdapter } from "./AppAdapter" +import { DownloadUpdateOptions } from "./AppUpdater" +import { BaseUpdater, InstallOptions } from "./BaseUpdater" +import { DOWNLOAD_PROGRESS } from "./main" +import { findFile } from "./providers/Provider" + +export class DebUpdater extends BaseUpdater { + constructor(options?: AllPublishOptions | null, app?: AppAdapter) { + super(options, app) + } + + /*** @private */ + protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { + const provider = downloadUpdateOptions.updateInfoAndProvider.provider + const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "deb", ["AppImage", "rpm"])! + return this.executeDownload({ + fileExtension: "deb", + fileInfo, + downloadUpdateOptions, + task: async (updateFile, downloadOptions) => { + if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { + downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) + } + await this.httpExecutor.download(fileInfo.url, updateFile, downloadOptions) + }, + }) + } + + protected doInstall(options: InstallOptions): boolean { + const sudo = this.wrapSudo() + // pkexec doesn't want the command to be wrapped in " quotes + const wrapper = /pkexec/i.test(sudo) ? "" : `"` + const cmd = ["dpkg", "-i", options.installerPath, "||", "apt-get", "install", "-f", "-y"] + this.spawnSyncLog(sudo, [`${wrapper}/bin/bash`, "-c", `'${cmd.join(" ")}'${wrapper}`]) + if (options.isForceRunAfter) { + this.app.relaunch() + } + return true + } +} diff --git a/src/main/electron-updater/DownloadedUpdateHelper.ts b/src/main/electron-updater/DownloadedUpdateHelper.ts new file mode 100644 index 0000000..12a312c --- /dev/null +++ b/src/main/electron-updater/DownloadedUpdateHelper.ts @@ -0,0 +1,199 @@ +import { UpdateInfo } from "builder-util-runtime" +import { createHash } from "crypto" +import { createReadStream } from "fs" +// @ts-ignore +import * as isEqual from "lodash.isequal" +import { Logger, ResolvedUpdateFileInfo } from "./main" +import { pathExists, readJson, emptyDir, outputJson, unlink } from "fs-extra" +import * as path from "path" + +/** @private **/ +export class DownloadedUpdateHelper { + private _file: string | null = null + private _packageFile: string | null = null + + private versionInfo: UpdateInfo | null = null + private fileInfo: ResolvedUpdateFileInfo | null = null + + constructor(readonly cacheDir: string) {} + + private _downloadedFileInfo: CachedUpdateInfo | null = null + get downloadedFileInfo(): CachedUpdateInfo | null { + return this._downloadedFileInfo + } + + get file(): string | null { + return this._file + } + + get packageFile(): string | null { + return this._packageFile + } + + get cacheDirForPendingUpdate(): string { + return path.join(this.cacheDir, "pending") + } + + async validateDownloadedPath(updateFile: string, updateInfo: UpdateInfo, fileInfo: ResolvedUpdateFileInfo, logger: Logger): Promise { + if (this.versionInfo != null && this.file === updateFile && this.fileInfo != null) { + // update has already been downloaded from this running instance + // check here only existence, not checksum + if (isEqual(this.versionInfo, updateInfo) && isEqual(this.fileInfo.info, fileInfo.info) && (await pathExists(updateFile))) { + return updateFile + } else { + return null + } + } + + // update has already been downloaded from some previous app launch + const cachedUpdateFile = await this.getValidCachedUpdateFile(fileInfo, logger) + if (cachedUpdateFile === null) { + return null + } + logger.info(`Update has already been downloaded to ${updateFile}).`) + this._file = cachedUpdateFile + return cachedUpdateFile + } + + async setDownloadedFile( + downloadedFile: string, + packageFile: string | null, + versionInfo: UpdateInfo, + fileInfo: ResolvedUpdateFileInfo, + updateFileName: string, + isSaveCache: boolean + ): Promise { + this._file = downloadedFile + this._packageFile = packageFile + this.versionInfo = versionInfo + this.fileInfo = fileInfo + this._downloadedFileInfo = { + fileName: updateFileName, + sha512: fileInfo.info.sha512, + isAdminRightsRequired: fileInfo.info.isAdminRightsRequired === true, + } + + if (isSaveCache) { + await outputJson(this.getUpdateInfoFile(), this._downloadedFileInfo) + } + } + + async clear(): Promise { + this._file = null + this._packageFile = null + this.versionInfo = null + this.fileInfo = null + await this.cleanCacheDirForPendingUpdate() + } + + private async cleanCacheDirForPendingUpdate(): Promise { + try { + // remove stale data + await emptyDir(this.cacheDirForPendingUpdate) + } catch (ignore) { + // ignore + } + } + + /** + * Returns "update-info.json" which is created in the update cache directory's "pending" subfolder after the first update is downloaded. If the update file does not exist then the cache is cleared and recreated. If the update file exists then its properties are validated. + * @param fileInfo + * @param logger + */ + private async getValidCachedUpdateFile(fileInfo: ResolvedUpdateFileInfo, logger: Logger): Promise { + const updateInfoFilePath: string = this.getUpdateInfoFile() + + const doesUpdateInfoFileExist = await pathExists(updateInfoFilePath) + if (!doesUpdateInfoFileExist) { + return null + } + + let cachedInfo: CachedUpdateInfo + try { + cachedInfo = await readJson(updateInfoFilePath) + } catch (error: any) { + let message = `No cached update info available` + if (error.code !== "ENOENT") { + await this.cleanCacheDirForPendingUpdate() + message += ` (error on read: ${error.message})` + } + logger.info(message) + return null + } + + const isCachedInfoFileNameValid = cachedInfo?.fileName !== null ?? false + if (!isCachedInfoFileNameValid) { + logger.warn(`Cached update info is corrupted: no fileName, directory for cached update will be cleaned`) + await this.cleanCacheDirForPendingUpdate() + return null + } + + if (fileInfo.info.sha512 !== cachedInfo.sha512) { + logger.info( + `Cached update sha512 checksum doesn't match the latest available update. New update must be downloaded. Cached: ${cachedInfo.sha512}, expected: ${fileInfo.info.sha512}. Directory for cached update will be cleaned` + ) + await this.cleanCacheDirForPendingUpdate() + return null + } + + const updateFile = path.join(this.cacheDirForPendingUpdate, cachedInfo.fileName) + if (!(await pathExists(updateFile))) { + logger.info("Cached update file doesn't exist") + return null + } + + const sha512 = await hashFile(updateFile) + if (fileInfo.info.sha512 !== sha512) { + logger.warn(`Sha512 checksum doesn't match the latest available update. New update must be downloaded. Cached: ${sha512}, expected: ${fileInfo.info.sha512}`) + await this.cleanCacheDirForPendingUpdate() + return null + } + this._downloadedFileInfo = cachedInfo + return updateFile + } + + private getUpdateInfoFile(): string { + return path.join(this.cacheDirForPendingUpdate, "update-info.json") + } +} + +interface CachedUpdateInfo { + fileName: string + sha512: string + readonly isAdminRightsRequired: boolean +} + +function hashFile(file: string, algorithm = "sha512", encoding: "base64" | "hex" = "base64", options?: any): Promise { + return new Promise((resolve, reject) => { + const hash = createHash(algorithm) + hash.on("error", reject).setEncoding(encoding) + + createReadStream(file, { ...options, highWaterMark: 1024 * 1024 /* better to use more memory but hash faster */ }) + .on("error", reject) + .on("end", () => { + hash.end() + resolve(hash.read() as string) + }) + .pipe(hash, { end: false }) + }) +} + +export async function createTempUpdateFile(name: string, cacheDir: string, log: Logger): Promise { + // https://github.com/electron-userland/electron-builder/pull/2474#issuecomment-366481912 + let nameCounter = 0 + let result = path.join(cacheDir, name) + for (let i = 0; i < 3; i++) { + try { + await unlink(result) + return result + } catch (e: any) { + if (e.code === "ENOENT") { + return result + } + + log.warn(`Error on remove temp update file: ${e}`) + result = path.join(cacheDir, `${nameCounter++}-${name}`) + } + } + return result +} diff --git a/src/main/electron-updater/ElectronAppAdapter.ts b/src/main/electron-updater/ElectronAppAdapter.ts new file mode 100644 index 0000000..0999dad --- /dev/null +++ b/src/main/electron-updater/ElectronAppAdapter.ts @@ -0,0 +1,46 @@ +import * as path from "path" +import { AppAdapter, getAppCacheDir } from "./AppAdapter" + +export class ElectronAppAdapter implements AppAdapter { + constructor(private readonly app = require("electron").app) {} + + whenReady(): Promise { + return this.app.whenReady() + } + + get version(): string { + return this.app.getVersion() + } + + get name(): string { + return this.app.getName() + } + + get isPackaged(): boolean { + return this.app.isPackaged === true + } + + get appUpdateConfigPath(): string { + return this.isPackaged ? path.join(process.resourcesPath!, "app-update.yml") : path.join(this.app.getAppPath(), "dev-app-update.yml") + } + + get userDataPath(): string { + return this.app.getPath("userData") + } + + get baseCachePath(): string { + return getAppCacheDir() + } + + quit(): void { + this.app.quit() + } + + relaunch(): void { + this.app.relaunch() + } + + onQuit(handler: (exitCode: number) => void): void { + this.app.once("quit", (_: Event, exitCode: number) => handler(exitCode)) + } +} diff --git a/src/main/electron-updater/MacUpdater.ts b/src/main/electron-updater/MacUpdater.ts new file mode 100644 index 0000000..a989acb --- /dev/null +++ b/src/main/electron-updater/MacUpdater.ts @@ -0,0 +1,275 @@ +import { AllPublishOptions, newError, safeStringifyJson } from "builder-util-runtime" +import { pathExistsSync, stat } from "fs-extra" +import { createReadStream, copyFileSync } from "fs" +import * as path from "path" +import { createServer, IncomingMessage, Server, ServerResponse } from "http" +import { AppAdapter } from "./AppAdapter" +import { AppUpdater, DownloadUpdateOptions } from "./AppUpdater" +import { ResolvedUpdateFileInfo, UpdateDownloadedEvent } from "./main" +import { findFile } from "./providers/Provider" +import AutoUpdater = Electron.AutoUpdater +import { execFileSync } from "child_process" +import { randomBytes } from "crypto" + +export class MacUpdater extends AppUpdater { + private readonly nativeUpdater: AutoUpdater = require("electron").autoUpdater + + private squirrelDownloadedUpdate = false + + private server?: Server + + constructor(options?: AllPublishOptions, app?: AppAdapter) { + super(options, app) + + this.nativeUpdater.on("error", it => { + this._logger.warn(it) + this.emit("error", it) + }) + this.nativeUpdater.on("update-downloaded", () => { + this.squirrelDownloadedUpdate = true + this.debug("nativeUpdater.update-downloaded") + }) + } + + private debug(message: string): void { + if (this._logger.debug != null) { + this._logger.debug(message) + } + } + + private closeServerIfExists() { + if (this.server) { + this.debug("Closing proxy server") + this.server.close(err => { + if (err) { + this.debug("proxy server wasn't already open, probably attempted closing again as a safety check before quit") + } + }) + } + } + + protected async doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { + let files = downloadUpdateOptions.updateInfoAndProvider.provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info) + + const log = this._logger + + // detect if we are running inside Rosetta emulation + const sysctlRosettaInfoKey = "sysctl.proc_translated" + let isRosetta = false + try { + this.debug("Checking for macOS Rosetta environment") + const result = execFileSync("sysctl", [sysctlRosettaInfoKey], { encoding: "utf8" }) + isRosetta = result.includes(`${sysctlRosettaInfoKey}: 1`) + log.info(`Checked for macOS Rosetta environment (isRosetta=${isRosetta})`) + } catch (e: any) { + log.warn(`sysctl shell command to check for macOS Rosetta environment failed: ${e}`) + } + + let isArm64Mac = false + try { + this.debug("Checking for arm64 in uname") + const result = execFileSync("uname", ["-a"], { encoding: "utf8" }) + const isArm = result.includes("ARM") + log.info(`Checked 'uname -a': arm64=${isArm}`) + isArm64Mac = isArm64Mac || isArm + } catch (e: any) { + log.warn(`uname shell command to check for arm64 failed: ${e}`) + } + + isArm64Mac = isArm64Mac || process.arch === "arm64" || isRosetta + + // allow arm64 macs to install universal or rosetta2(x64) - https://github.com/electron-userland/electron-builder/pull/5524 + const isArm64 = (file: ResolvedUpdateFileInfo) => file.url.pathname.includes("arm64") || file.info.url?.includes("arm64") + if (isArm64Mac && files.some(isArm64)) { + files = files.filter(file => isArm64Mac === isArm64(file)) + } else { + files = files.filter(file => !isArm64(file)) + } + + const zipFileInfo = findFile(files, "zip", ["pkg", "dmg"]) + + if (zipFileInfo == null) { + throw newError(`ZIP file not provided: ${safeStringifyJson(files)}`, "ERR_UPDATER_ZIP_FILE_NOT_FOUND") + } + + const provider = downloadUpdateOptions.updateInfoAndProvider.provider + const CURRENT_MAC_APP_ZIP_FILE_NAME = "update.zip" + let cachedUpdateFile: string = "" + + return this.executeDownload({ + fileExtension: "zip", + fileInfo: zipFileInfo, + downloadUpdateOptions, + task: async (destinationFile, downloadOptions) => { + cachedUpdateFile = path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_MAC_APP_ZIP_FILE_NAME) + const canDifferentialDownload = () => { + if (!pathExistsSync(cachedUpdateFile)) { + log.info("Unable to locate previous update.zip for differential download (is this first install?), falling back to full download") + return false + } + return !downloadUpdateOptions.disableDifferentialDownload + } + let differentialDownloadFailed = true + if (canDifferentialDownload()) { + differentialDownloadFailed = await this.differentialDownloadInstaller(zipFileInfo, downloadUpdateOptions, destinationFile, provider, CURRENT_MAC_APP_ZIP_FILE_NAME) + } + + if (differentialDownloadFailed) { + await this.httpExecutor.download(zipFileInfo.url, destinationFile, downloadOptions) + } + }, + done: event => { + try { + copyFileSync(event.downloadedFile, cachedUpdateFile) + } catch (error: any) { + this._logger.error(`Unable to copy file for caching: ${error.message}`) + } + return this.updateDownloaded(zipFileInfo, event) + }, + }) + } + + private async updateDownloaded(zipFileInfo: ResolvedUpdateFileInfo, event: UpdateDownloadedEvent): Promise> { + const downloadedFile = event.downloadedFile + const updateFileSize = zipFileInfo.info.size ?? (await stat(downloadedFile)).size + + const log = this._logger + const logContext = `fileToProxy=${zipFileInfo.url.href}` + this.closeServerIfExists() + this.debug(`Creating proxy server for native Squirrel.Mac (${logContext})`) + this.server = createServer() + this.debug(`Proxy server for native Squirrel.Mac is created (${logContext})`) + this.server.on("close", () => { + log.info(`Proxy server for native Squirrel.Mac is closed (${logContext})`) + }) + + // must be called after server is listening, otherwise address is null + const getServerUrl = (s: Server): string => { + const address = s.address() + if (typeof address === "string") { + return address + } + return `http://127.0.0.1:${address?.port}` + } + + return await new Promise>((resolve, reject) => { + const pass = randomBytes(64).toString("base64").replace(/\//g, "_").replace(/\+/g, "-") + const authInfo = Buffer.from(`autoupdater:${pass}`, "ascii") + + // insecure random is ok + const fileUrl = `/${randomBytes(64).toString("hex")}.zip` + this.server!.on("request", (request: IncomingMessage, response: ServerResponse) => { + const requestUrl = request.url! + log.info(`${requestUrl} requested`) + if (requestUrl === "/") { + // check for basic auth header + if (!request.headers.authorization || request.headers.authorization.indexOf("Basic ") === -1) { + response.statusCode = 401 + response.statusMessage = "Invalid Authentication Credentials" + response.end() + log.warn("No authenthication info") + return + } + + // verify auth credentials + const base64Credentials = request.headers.authorization.split(" ")[1] + const credentials = Buffer.from(base64Credentials, "base64").toString("ascii") + const [username, password] = credentials.split(":") + if (username !== "autoupdater" || password !== pass) { + response.statusCode = 401 + response.statusMessage = "Invalid Authentication Credentials" + response.end() + log.warn("Invalid authenthication credentials") + return + } + + const data = Buffer.from(`{ "url": "${getServerUrl(this.server!)}${fileUrl}" }`) + response.writeHead(200, { "Content-Type": "application/json", "Content-Length": data.length }) + response.end(data) + return + } + + if (!requestUrl.startsWith(fileUrl)) { + log.warn(`${requestUrl} requested, but not supported`) + response.writeHead(404) + response.end() + return + } + + log.info(`${fileUrl} requested by Squirrel.Mac, pipe ${downloadedFile}`) + + let errorOccurred = false + response.on("finish", () => { + if (!errorOccurred) { + this.nativeUpdater.removeListener("error", reject) + resolve([]) + } + }) + + const readStream = createReadStream(downloadedFile) + readStream.on("error", error => { + try { + response.end() + } catch (e: any) { + log.warn(`cannot end response: ${e}`) + } + errorOccurred = true + this.nativeUpdater.removeListener("error", reject) + reject(new Error(`Cannot pipe "${downloadedFile}": ${error}`)) + }) + + response.writeHead(200, { + "Content-Type": "application/zip", + "Content-Length": updateFileSize, + }) + readStream.pipe(response) + }) + + this.debug(`Proxy server for native Squirrel.Mac is starting to listen (${logContext})`) + + this.server!.listen(0, "127.0.0.1", () => { + this.debug(`Proxy server for native Squirrel.Mac is listening (address=${getServerUrl(this.server!)}, ${logContext})`) + this.nativeUpdater.setFeedURL({ + url: getServerUrl(this.server!), + headers: { + "Cache-Control": "no-cache", + Authorization: `Basic ${authInfo.toString("base64")}`, + }, + }) + + // The update has been downloaded and is ready to be served to Squirrel + this.dispatchUpdateDownloaded(event) + + if (this.autoInstallOnAppQuit) { + this.nativeUpdater.once("error", reject) + // This will trigger fetching and installing the file on Squirrel side + this.nativeUpdater.checkForUpdates() + } else { + resolve([]) + } + }) + }) + } + + quitAndInstall(): void { + if (this.squirrelDownloadedUpdate) { + // update already fetched by Squirrel, it's ready to install + this.nativeUpdater.quitAndInstall() + this.closeServerIfExists() + } else { + // Quit and install as soon as Squirrel get the update + this.nativeUpdater.on("update-downloaded", () => { + this.nativeUpdater.quitAndInstall() + this.closeServerIfExists() + }) + + if (!this.autoInstallOnAppQuit) { + /** + * If this was not `true` previously then MacUpdater.doDownloadUpdate() + * would not actually initiate the downloading by electron's autoUpdater + */ + this.nativeUpdater.checkForUpdates() + } + } + } +} diff --git a/src/main/electron-updater/NsisUpdater.ts b/src/main/electron-updater/NsisUpdater.ts new file mode 100644 index 0000000..de4780c --- /dev/null +++ b/src/main/electron-updater/NsisUpdater.ts @@ -0,0 +1,209 @@ +import { AllPublishOptions, newError, PackageFileInfo, CURRENT_APP_INSTALLER_FILE_NAME, CURRENT_APP_PACKAGE_FILE_NAME } from "builder-util-runtime" +import * as path from "path" +import { AppAdapter } from "./AppAdapter" +import { DownloadUpdateOptions } from "./AppUpdater" +import { BaseUpdater, InstallOptions } from "./BaseUpdater" +import { DifferentialDownloaderOptions } from "./differentialDownloader/DifferentialDownloader" +import { FileWithEmbeddedBlockMapDifferentialDownloader } from "./differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader" +import { DOWNLOAD_PROGRESS, verifyUpdateCodeSignature } from "./main" +import { findFile, Provider } from "./providers/Provider" +import { unlink } from "fs-extra" +import { verifySignature } from "./windowsExecutableCodeSignatureVerifier" +import { URL } from "url" + +export class NsisUpdater extends BaseUpdater { + /** + * Specify custom install directory path + * + */ + installDirectory?: string + + constructor(options?: AllPublishOptions | null, app?: AppAdapter) { + super(options, app) + } + + protected _verifyUpdateCodeSignature: verifyUpdateCodeSignature = (publisherNames: Array, unescapedTempUpdateFile: string) => + verifySignature(publisherNames, unescapedTempUpdateFile, this._logger) + + /** + * The verifyUpdateCodeSignature. You can pass [win-verify-signature](https://github.com/beyondkmp/win-verify-trust) or another custom verify function: ` (publisherName: string[], path: string) => Promise`. + * The default verify function uses [windowsExecutableCodeSignatureVerifier](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/windowsExecutableCodeSignatureVerifier.ts) + */ + get verifyUpdateCodeSignature(): verifyUpdateCodeSignature { + return this._verifyUpdateCodeSignature + } + + set verifyUpdateCodeSignature(value: verifyUpdateCodeSignature) { + if (value) { + this._verifyUpdateCodeSignature = value + } + } + + /*** @private */ + protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { + const provider = downloadUpdateOptions.updateInfoAndProvider.provider + const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "exe")! + return this.executeDownload({ + fileExtension: "exe", + downloadUpdateOptions, + fileInfo, + task: async (destinationFile, downloadOptions, packageFile, removeTempDirIfAny) => { + const packageInfo = fileInfo.packageInfo + const isWebInstaller = packageInfo != null && packageFile != null + if (isWebInstaller && downloadUpdateOptions.disableWebInstaller) { + throw newError( + `Unable to download new version ${downloadUpdateOptions.updateInfoAndProvider.info.version}. Web Installers are disabled`, + "ERR_UPDATER_WEB_INSTALLER_DISABLED" + ) + } + if (!isWebInstaller && !downloadUpdateOptions.disableWebInstaller) { + this._logger.warn( + "disableWebInstaller is set to false, you should set it to true if you do not plan on using a web installer. This will default to true in a future version." + ) + } + if ( + isWebInstaller || + downloadUpdateOptions.disableDifferentialDownload || + (await this.differentialDownloadInstaller(fileInfo, downloadUpdateOptions, destinationFile, provider, CURRENT_APP_INSTALLER_FILE_NAME)) + ) { + await this.httpExecutor.download(fileInfo.url, destinationFile, downloadOptions) + } + + const signatureVerificationStatus = await this.verifySignature(destinationFile) + if (signatureVerificationStatus != null) { + await removeTempDirIfAny() + // noinspection ThrowInsideFinallyBlockJS + throw newError( + `New version ${downloadUpdateOptions.updateInfoAndProvider.info.version} is not signed by the application owner: ${signatureVerificationStatus}`, + "ERR_UPDATER_INVALID_SIGNATURE" + ) + } + + if (isWebInstaller) { + if (await this.differentialDownloadWebPackage(downloadUpdateOptions, packageInfo, packageFile, provider)) { + try { + await this.httpExecutor.download(new URL(packageInfo.path), packageFile, { + headers: downloadUpdateOptions.requestHeaders, + cancellationToken: downloadUpdateOptions.cancellationToken, + sha512: packageInfo.sha512, + }) + } catch (e: any) { + try { + await unlink(packageFile) + } catch (ignored) { + // ignore + } + + throw e + } + } + } + }, + }) + } + + // $certificateInfo = (Get-AuthenticodeSignature 'xxx\yyy.exe' + // | where {$_.Status.Equals([System.Management.Automation.SignatureStatus]::Valid) -and $_.SignerCertificate.Subject.Contains("CN=siemens.com")}) + // | Out-String ; if ($certificateInfo) { exit 0 } else { exit 1 } + private async verifySignature(tempUpdateFile: string): Promise { + let publisherName: Array | string | null + try { + publisherName = (await this.configOnDisk.value).publisherName + if (publisherName == null) { + return null + } + } catch (e: any) { + if (e.code === "ENOENT") { + // no app-update.yml + return null + } + throw e + } + return await this._verifyUpdateCodeSignature(Array.isArray(publisherName) ? publisherName : [publisherName], tempUpdateFile) + } + + protected doInstall(options: InstallOptions): boolean { + const args = ["--updated"] + if (options.isSilent) { + args.push("/S") + } + + if (options.isForceRunAfter) { + args.push("--force-run") + } + + if (this.installDirectory) { + // maybe check if folder exists + args.push(`/D=${this.installDirectory}`) + } + + const packagePath = this.downloadedUpdateHelper == null ? null : this.downloadedUpdateHelper.packageFile + if (packagePath != null) { + // only = form is supported + args.push(`--package-file=${packagePath}`) + } + + const callUsingElevation = (): void => { + this.spawnLog(path.join(process.resourcesPath!, "elevate.exe"), [options.installerPath].concat(args)).catch(e => this.dispatchError(e)) + } + + if (options.isAdminRightsRequired) { + this._logger.info("isAdminRightsRequired is set to true, run installer using elevate.exe") + callUsingElevation() + return true + } + + this.spawnLog(options.installerPath, args).catch((e: Error) => { + // https://github.com/electron-userland/electron-builder/issues/1129 + // Node 8 sends errors: https://nodejs.org/dist/latest-v8.x/docs/api/errors.html#errors_common_system_errors + const errorCode = (e as NodeJS.ErrnoException).code + this._logger.info( + `Cannot run installer: error code: ${errorCode}, error message: "${e.message}", will be executed again using elevate if EACCES, and will try to use electron.shell.openItem if ENOENT` + ) + if (errorCode === "UNKNOWN" || errorCode === "EACCES") { + callUsingElevation() + } else if (errorCode === "ENOENT") { + require("electron") + .shell.openPath(options.installerPath) + .catch((err: Error) => this.dispatchError(err)) + } else { + this.dispatchError(e) + } + }) + return true + } + + private async differentialDownloadWebPackage( + downloadUpdateOptions: DownloadUpdateOptions, + packageInfo: PackageFileInfo, + packagePath: string, + provider: Provider + ): Promise { + if (packageInfo.blockMapSize == null) { + return true + } + + try { + const downloadOptions: DifferentialDownloaderOptions = { + newUrl: new URL(packageInfo.path), + oldFile: path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_APP_PACKAGE_FILE_NAME), + logger: this._logger, + newFile: packagePath, + requestHeaders: this.requestHeaders, + isUseMultipleRangeRequest: provider.isUseMultipleRangeRequest, + cancellationToken: downloadUpdateOptions.cancellationToken, + } + + if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { + downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) + } + + await new FileWithEmbeddedBlockMapDifferentialDownloader(packageInfo, this.httpExecutor, downloadOptions).download() + } catch (e: any) { + this._logger.error(`Cannot download differentially, fallback to full download: ${e.stack || e}`) + // during test (developer machine mac or linux) we must throw error + return process.platform === "win32" + } + return false + } +} diff --git a/src/main/electron-updater/RpmUpdater.ts b/src/main/electron-updater/RpmUpdater.ts new file mode 100644 index 0000000..3b7f9f9 --- /dev/null +++ b/src/main/electron-updater/RpmUpdater.ts @@ -0,0 +1,66 @@ +import { AllPublishOptions } from "builder-util-runtime" +import { AppAdapter } from "./AppAdapter" +import { DownloadUpdateOptions } from "./AppUpdater" +import { BaseUpdater, InstallOptions } from "./BaseUpdater" +import { DOWNLOAD_PROGRESS } from "./main" +import { findFile } from "./providers/Provider" + +export class RpmUpdater extends BaseUpdater { + constructor(options?: AllPublishOptions | null, app?: AppAdapter) { + super(options, app) + } + + /*** @private */ + protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { + const provider = downloadUpdateOptions.updateInfoAndProvider.provider + const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "rpm", ["AppImage", "deb"])! + return this.executeDownload({ + fileExtension: "rpm", + fileInfo, + downloadUpdateOptions, + task: async (updateFile, downloadOptions) => { + if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { + downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) + } + await this.httpExecutor.download(fileInfo.url, updateFile, downloadOptions) + }, + }) + } + + protected doInstall(options: InstallOptions): boolean { + const upgradePath = options.installerPath + const sudo = this.wrapSudo() + // pkexec doesn't want the command to be wrapped in " quotes + const wrapper = /pkexec/i.test(sudo) ? "" : `"` + const packageManager = this.spawnSyncLog("which zypper") + let cmd: string[] + if (!packageManager) { + const packageManager = this.spawnSyncLog("which dnf || which yum") + cmd = [packageManager, "-y", "remove", `'${this.app.name}'`, ";", packageManager, "-y", "install", upgradePath] + } else { + cmd = [ + packageManager, + "remove", + "-y", + `'${this.app.name}'`, + ";", + packageManager, + "clean", + "--all", + ";", + packageManager, + "--no-refresh", + "install", + "--allow-unsigned-rpm", + "-y", + "-f", + upgradePath, + ] + } + this.spawnSyncLog(sudo, [`${wrapper}/bin/bash`, "-c", `'${cmd.join(" ")}'${wrapper}`]) + if (options.isForceRunAfter) { + this.app.relaunch() + } + return true + } +} diff --git a/src/main/electron-updater/differentialDownloader/DataSplitter.ts b/src/main/electron-updater/differentialDownloader/DataSplitter.ts new file mode 100644 index 0000000..0862529 --- /dev/null +++ b/src/main/electron-updater/differentialDownloader/DataSplitter.ts @@ -0,0 +1,233 @@ +import { newError } from "builder-util-runtime" +import { createReadStream } from "fs" +import { Writable } from "stream" +import { Operation, OperationKind } from "./downloadPlanBuilder" + +const DOUBLE_CRLF = Buffer.from("\r\n\r\n") + +enum ReadState { + INIT, + HEADER, + BODY, +} + +export interface PartListDataTask { + readonly oldFileFd: number + readonly tasks: Array + readonly start: number + readonly end: number +} + +export function copyData(task: Operation, out: Writable, oldFileFd: number, reject: (error: Error) => void, resolve: () => void): void { + const readStream = createReadStream("", { + fd: oldFileFd, + autoClose: false, + start: task.start, + // end is inclusive + end: task.end - 1, + }) + readStream.on("error", reject) + readStream.once("end", resolve) + readStream.pipe(out, { + end: false, + }) +} + +export class DataSplitter extends Writable { + partIndex = -1 + + private headerListBuffer: Buffer | null = null + private readState = ReadState.INIT + private ignoreByteCount = 0 + private remainingPartDataCount = 0 + + private readonly boundaryLength: number + + constructor( + private readonly out: Writable, + private readonly options: PartListDataTask, + private readonly partIndexToTaskIndex: Map, + boundary: string, + private readonly partIndexToLength: Array, + private readonly finishHandler: () => any + ) { + super() + + this.boundaryLength = boundary.length + 4 /* size of \r\n-- */ + // first chunk doesn't start with \r\n + this.ignoreByteCount = this.boundaryLength - 2 + } + + get isFinished(): boolean { + return this.partIndex === this.partIndexToLength.length + } + + // noinspection JSUnusedGlobalSymbols + _write(data: Buffer, encoding: string, callback: (error?: Error) => void): void { + if (this.isFinished) { + console.error(`Trailing ignored data: ${data.length} bytes`) + return + } + + this.handleData(data).then(callback).catch(callback) + } + + private async handleData(chunk: Buffer): Promise { + let start = 0 + + if (this.ignoreByteCount !== 0 && this.remainingPartDataCount !== 0) { + throw newError("Internal error", "ERR_DATA_SPLITTER_BYTE_COUNT_MISMATCH") + } + + if (this.ignoreByteCount > 0) { + const toIgnore = Math.min(this.ignoreByteCount, chunk.length) + this.ignoreByteCount -= toIgnore + start = toIgnore + } else if (this.remainingPartDataCount > 0) { + const toRead = Math.min(this.remainingPartDataCount, chunk.length) + this.remainingPartDataCount -= toRead + await this.processPartData(chunk, 0, toRead) + start = toRead + } + + if (start === chunk.length) { + return + } + + if (this.readState === ReadState.HEADER) { + const headerListEnd = this.searchHeaderListEnd(chunk, start) + if (headerListEnd === -1) { + return + } + + start = headerListEnd + this.readState = ReadState.BODY + // header list is ignored, we don't need it + this.headerListBuffer = null + } + + while (true) { + if (this.readState === ReadState.BODY) { + this.readState = ReadState.INIT + } else { + this.partIndex++ + + let taskIndex = this.partIndexToTaskIndex.get(this.partIndex) + if (taskIndex == null) { + if (this.isFinished) { + taskIndex = this.options.end + } else { + throw newError("taskIndex is null", "ERR_DATA_SPLITTER_TASK_INDEX_IS_NULL") + } + } + + const prevTaskIndex = this.partIndex === 0 ? this.options.start : this.partIndexToTaskIndex.get(this.partIndex - 1)! + 1 /* prev part is download, next maybe copy */ + if (prevTaskIndex < taskIndex) { + await this.copyExistingData(prevTaskIndex, taskIndex) + } else if (prevTaskIndex > taskIndex) { + throw newError("prevTaskIndex must be < taskIndex", "ERR_DATA_SPLITTER_TASK_INDEX_ASSERT_FAILED") + } + + if (this.isFinished) { + this.onPartEnd() + this.finishHandler() + return + } + + start = this.searchHeaderListEnd(chunk, start) + + if (start === -1) { + this.readState = ReadState.HEADER + return + } + } + + const partLength = this.partIndexToLength[this.partIndex] + const end = start + partLength + const effectiveEnd = Math.min(end, chunk.length) + await this.processPartStarted(chunk, start, effectiveEnd) + this.remainingPartDataCount = partLength - (effectiveEnd - start) + if (this.remainingPartDataCount > 0) { + return + } + + start = end + this.boundaryLength + if (start >= chunk.length) { + this.ignoreByteCount = this.boundaryLength - (chunk.length - end) + return + } + } + } + + private copyExistingData(index: number, end: number): Promise { + return new Promise((resolve, reject) => { + const w = (): void => { + if (index === end) { + resolve() + return + } + + const task = this.options.tasks[index] + if (task.kind !== OperationKind.COPY) { + reject(new Error("Task kind must be COPY")) + return + } + + copyData(task, this.out, this.options.oldFileFd, reject, () => { + index++ + w() + }) + } + w() + }) + } + + private searchHeaderListEnd(chunk: Buffer, readOffset: number): number { + const headerListEnd = chunk.indexOf(DOUBLE_CRLF, readOffset) + if (headerListEnd !== -1) { + return headerListEnd + DOUBLE_CRLF.length + } + + // not all headers data were received, save to buffer + const partialChunk = readOffset === 0 ? chunk : chunk.slice(readOffset) + if (this.headerListBuffer == null) { + this.headerListBuffer = partialChunk + } else { + this.headerListBuffer = Buffer.concat([this.headerListBuffer, partialChunk]) + } + return -1 + } + + private actualPartLength = 0 + + private onPartEnd(): void { + const expectedLength = this.partIndexToLength[this.partIndex - 1] + if (this.actualPartLength !== expectedLength) { + throw newError(`Expected length: ${expectedLength} differs from actual: ${this.actualPartLength}`, "ERR_DATA_SPLITTER_LENGTH_MISMATCH") + } + this.actualPartLength = 0 + } + + private processPartStarted(data: Buffer, start: number, end: number): Promise { + if (this.partIndex !== 0) { + this.onPartEnd() + } + return this.processPartData(data, start, end) + } + + private processPartData(data: Buffer, start: number, end: number): Promise { + this.actualPartLength += end - start + const out = this.out + if (out.write(start === 0 && data.length === end ? data : data.slice(start, end))) { + return Promise.resolve() + } else { + return new Promise((resolve, reject) => { + out.on("error", reject) + out.once("drain", () => { + out.removeListener("error", reject) + resolve() + }) + }) + } + } +} diff --git a/src/main/electron-updater/differentialDownloader/DifferentialDownloader.ts b/src/main/electron-updater/differentialDownloader/DifferentialDownloader.ts new file mode 100644 index 0000000..dea2348 --- /dev/null +++ b/src/main/electron-updater/differentialDownloader/DifferentialDownloader.ts @@ -0,0 +1,320 @@ +import { BlockMapDataHolder, createHttpError, DigestTransform, HttpExecutor, configureRequestUrl, configureRequestOptions } from "builder-util-runtime" +import { BlockMap } from "builder-util-runtime/out/blockMapApi" +import { close, open } from "fs-extra" +import { createWriteStream } from "fs" +import { OutgoingHttpHeaders, RequestOptions } from "http" +import { ProgressInfo, CancellationToken } from "builder-util-runtime" +import { Logger } from "../main" +import { copyData } from "./DataSplitter" +import { URL } from "url" +import { computeOperations, Operation, OperationKind } from "./downloadPlanBuilder" +import { checkIsRangesSupported, executeTasksUsingMultipleRangeRequests } from "./multipleRangeDownloader" +import { ProgressDifferentialDownloadCallbackTransform, ProgressDifferentialDownloadInfo } from "./ProgressDifferentialDownloadCallbackTransform" + +export interface DifferentialDownloaderOptions { + readonly oldFile: string + readonly newUrl: URL + readonly logger: Logger + readonly newFile: string + + readonly requestHeaders: OutgoingHttpHeaders | null + + readonly isUseMultipleRangeRequest?: boolean + + readonly cancellationToken: CancellationToken + onProgress?: (progress: ProgressInfo) => void +} + +export abstract class DifferentialDownloader { + fileMetadataBuffer: Buffer | null = null + + private readonly logger: Logger + + // noinspection TypeScriptAbstractClassConstructorCanBeMadeProtected + constructor( + protected readonly blockAwareFileInfo: BlockMapDataHolder, + readonly httpExecutor: HttpExecutor, + readonly options: DifferentialDownloaderOptions + ) { + this.logger = options.logger + } + + createRequestOptions(): RequestOptions { + const result = { + headers: { + ...this.options.requestHeaders, + accept: "*/*", + }, + } + configureRequestUrl(this.options.newUrl, result) + // user-agent, cache-control and other common options + configureRequestOptions(result) + return result + } + + protected doDownload(oldBlockMap: BlockMap, newBlockMap: BlockMap): Promise { + // we don't check other metadata like compressionMethod - generic check that it is make sense to differentially update is suitable for it + if (oldBlockMap.version !== newBlockMap.version) { + throw new Error(`version is different (${oldBlockMap.version} - ${newBlockMap.version}), full download is required`) + } + + const logger = this.logger + const operations = computeOperations(oldBlockMap, newBlockMap, logger) + if (logger.debug != null) { + logger.debug(JSON.stringify(operations, null, 2)) + } + + let downloadSize = 0 + let copySize = 0 + for (const operation of operations) { + const length = operation.end - operation.start + if (operation.kind === OperationKind.DOWNLOAD) { + downloadSize += length + } else { + copySize += length + } + } + + const newSize = this.blockAwareFileInfo.size + if (downloadSize + copySize + (this.fileMetadataBuffer == null ? 0 : this.fileMetadataBuffer.length) !== newSize) { + throw new Error(`Internal error, size mismatch: downloadSize: ${downloadSize}, copySize: ${copySize}, newSize: ${newSize}`) + } + + logger.info(`Full: ${formatBytes(newSize)}, To download: ${formatBytes(downloadSize)} (${Math.round(downloadSize / (newSize / 100))}%)`) + + return this.downloadFile(operations) + } + + private downloadFile(tasks: Array): Promise { + const fdList: Array = [] + const closeFiles = (): Promise> => { + return Promise.all( + fdList.map(openedFile => { + return close(openedFile.descriptor).catch((e: any) => { + this.logger.error(`cannot close file "${openedFile.path}": ${e}`) + }) + }) + ) + } + return this.doDownloadFile(tasks, fdList) + .then(closeFiles) + .catch((e: any) => { + // then must be after catch here (since then always throws error) + return closeFiles() + .catch(closeFilesError => { + // closeFiles never throw error, but just to be sure + try { + this.logger.error(`cannot close files: ${closeFilesError}`) + } catch (errorOnLog) { + try { + console.error(errorOnLog) + } catch (ignored) { + // ok, give up and ignore error + } + } + throw e + }) + .then(() => { + throw e + }) + }) + } + + private async doDownloadFile(tasks: Array, fdList: Array): Promise { + const oldFileFd = await open(this.options.oldFile, "r") + fdList.push({ descriptor: oldFileFd, path: this.options.oldFile }) + const newFileFd = await open(this.options.newFile, "w") + fdList.push({ descriptor: newFileFd, path: this.options.newFile }) + const fileOut = createWriteStream(this.options.newFile, { fd: newFileFd }) + await new Promise((resolve, reject) => { + const streams: Array = [] + + // Create our download info transformer if we have one + let downloadInfoTransform: ProgressDifferentialDownloadCallbackTransform | undefined = undefined + if (!this.options.isUseMultipleRangeRequest && this.options.onProgress) { + // TODO: Does not support multiple ranges (someone feel free to PR this!) + const expectedByteCounts: Array = [] + let grandTotalBytes = 0 + + for (const task of tasks) { + if (task.kind === OperationKind.DOWNLOAD) { + expectedByteCounts.push(task.end - task.start) + grandTotalBytes += task.end - task.start + } + } + + const progressDifferentialDownloadInfo: ProgressDifferentialDownloadInfo = { + expectedByteCounts: expectedByteCounts, + grandTotal: grandTotalBytes, + } + + downloadInfoTransform = new ProgressDifferentialDownloadCallbackTransform(progressDifferentialDownloadInfo, this.options.cancellationToken, this.options.onProgress) + streams.push(downloadInfoTransform) + } + + const digestTransform = new DigestTransform(this.blockAwareFileInfo.sha512) + // to simply debug, do manual validation to allow file to be fully written + digestTransform.isValidateOnEnd = false + streams.push(digestTransform) + + // noinspection JSArrowFunctionCanBeReplacedWithShorthand + fileOut.on("finish", () => { + ;(fileOut.close as any)(() => { + // remove from fd list because closed successfully + fdList.splice(1, 1) + try { + digestTransform.validate() + } catch (e: any) { + reject(e) + return + } + + resolve(undefined) + }) + }) + + streams.push(fileOut) + + let lastStream = null + for (const stream of streams) { + stream.on("error", reject) + if (lastStream == null) { + lastStream = stream + } else { + lastStream = lastStream.pipe(stream) + } + } + + const firstStream = streams[0] + + let w: any + if (this.options.isUseMultipleRangeRequest) { + w = executeTasksUsingMultipleRangeRequests(this, tasks, firstStream, oldFileFd, reject) + w(0) + return + } + + let downloadOperationCount = 0 + let actualUrl: string | null = null + this.logger.info(`Differential download: ${this.options.newUrl}`) + + const requestOptions = this.createRequestOptions() + ;(requestOptions as any).redirect = "manual" + + w = (index: number): void => { + if (index >= tasks.length) { + if (this.fileMetadataBuffer != null) { + firstStream.write(this.fileMetadataBuffer) + } + firstStream.end() + return + } + + const operation = tasks[index++] + if (operation.kind === OperationKind.COPY) { + // We are copying, let's not send status updates to the UI + if (downloadInfoTransform) { + downloadInfoTransform.beginFileCopy() + } + + copyData(operation, firstStream, oldFileFd, reject, () => w(index)) + return + } + + const range = `bytes=${operation.start}-${operation.end - 1}` + requestOptions.headers!.range = range + + this.logger?.debug?.(`download range: ${range}`) + + // We are starting to download + if (downloadInfoTransform) { + downloadInfoTransform.beginRangeDownload() + } + + const request = this.httpExecutor.createRequest(requestOptions, response => { + response.on("error", reject) + response.on("abort", () => { + reject(new Error("response has been aborted by the server")) + }) + // Electron net handles redirects automatically, our NodeJS test server doesn't use redirects - so, we don't check 3xx codes. + if (response.statusCode >= 400) { + reject(createHttpError(response)) + } + + response.pipe(firstStream, { + end: false, + }) + response.once("end", () => { + // Pass on that we are downloading a segment + if (downloadInfoTransform) { + downloadInfoTransform.endRangeDownload() + } + + if (++downloadOperationCount === 100) { + downloadOperationCount = 0 + setTimeout(() => w(index), 1000) + } else { + w(index) + } + }) + }) + request.on("redirect", (statusCode: number, method: string, redirectUrl: string) => { + this.logger.info(`Redirect to ${removeQuery(redirectUrl)}`) + actualUrl = redirectUrl + configureRequestUrl(new URL(actualUrl), requestOptions) + request.followRedirect() + }) + this.httpExecutor.addErrorAndTimeoutHandlers(request, reject) + request.end() + } + + w(0) + }) + } + + protected async readRemoteBytes(start: number, endInclusive: number): Promise { + const buffer = Buffer.allocUnsafe(endInclusive + 1 - start) + const requestOptions = this.createRequestOptions() + requestOptions.headers!.range = `bytes=${start}-${endInclusive}` + let position = 0 + await this.request(requestOptions, chunk => { + chunk.copy(buffer, position) + position += chunk.length + }) + + if (position !== buffer.length) { + throw new Error(`Received data length ${position} is not equal to expected ${buffer.length}`) + } + return buffer + } + + private request(requestOptions: RequestOptions, dataHandler: (chunk: Buffer) => void): Promise { + return new Promise((resolve, reject) => { + const request = this.httpExecutor.createRequest(requestOptions, response => { + if (!checkIsRangesSupported(response, reject)) { + return + } + + response.on("data", dataHandler) + response.on("end", () => resolve()) + }) + this.httpExecutor.addErrorAndTimeoutHandlers(request, reject) + request.end() + }) + } +} + +function formatBytes(value: number, symbol = " KB"): string { + return new Intl.NumberFormat("en").format((value / 1024).toFixed(2) as any) + symbol +} + +// safety +function removeQuery(url: string): string { + const index = url.indexOf("?") + return index < 0 ? url : url.substring(0, index) +} + +interface OpenedFile { + readonly descriptor: number + readonly path: string +} diff --git a/src/main/electron-updater/differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader.ts b/src/main/electron-updater/differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader.ts new file mode 100644 index 0000000..d4446db --- /dev/null +++ b/src/main/electron-updater/differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader.ts @@ -0,0 +1,37 @@ +import { BlockMap } from "builder-util-runtime/out/blockMapApi" +import { close, fstat, open, read } from "fs-extra" +import { DifferentialDownloader } from "./DifferentialDownloader" +import { inflateRawSync } from "zlib" + +export class FileWithEmbeddedBlockMapDifferentialDownloader extends DifferentialDownloader { + async download(): Promise { + const packageInfo = this.blockAwareFileInfo + const fileSize = packageInfo.size! + const offset = fileSize - (packageInfo.blockMapSize! + 4) + this.fileMetadataBuffer = await this.readRemoteBytes(offset, fileSize - 1) + const newBlockMap = readBlockMap(this.fileMetadataBuffer.slice(0, this.fileMetadataBuffer.length - 4)) + await this.doDownload(await readEmbeddedBlockMapData(this.options.oldFile), newBlockMap) + } +} + +function readBlockMap(data: Buffer): BlockMap { + return JSON.parse(inflateRawSync(data).toString()) +} + +async function readEmbeddedBlockMapData(file: string): Promise { + const fd = await open(file, "r") + try { + const fileSize = (await fstat(fd)).size + const sizeBuffer = Buffer.allocUnsafe(4) + await read(fd, sizeBuffer, 0, sizeBuffer.length, fileSize - sizeBuffer.length) + + const dataBuffer = Buffer.allocUnsafe(sizeBuffer.readUInt32BE(0)) + await read(fd, dataBuffer, 0, dataBuffer.length, fileSize - sizeBuffer.length - dataBuffer.length) + await close(fd) + + return readBlockMap(dataBuffer) + } catch (e: any) { + await close(fd) + throw e + } +} diff --git a/src/main/electron-updater/differentialDownloader/GenericDifferentialDownloader.ts b/src/main/electron-updater/differentialDownloader/GenericDifferentialDownloader.ts new file mode 100644 index 0000000..b7727d6 --- /dev/null +++ b/src/main/electron-updater/differentialDownloader/GenericDifferentialDownloader.ts @@ -0,0 +1,8 @@ +import { BlockMap } from "builder-util-runtime/out/blockMapApi" +import { DifferentialDownloader } from "./DifferentialDownloader" + +export class GenericDifferentialDownloader extends DifferentialDownloader { + download(oldBlockMap: BlockMap, newBlockMap: BlockMap): Promise { + return this.doDownload(oldBlockMap, newBlockMap) + } +} diff --git a/src/main/electron-updater/differentialDownloader/ProgressDifferentialDownloadCallbackTransform.ts b/src/main/electron-updater/differentialDownloader/ProgressDifferentialDownloadCallbackTransform.ts new file mode 100644 index 0000000..8e51a60 --- /dev/null +++ b/src/main/electron-updater/differentialDownloader/ProgressDifferentialDownloadCallbackTransform.ts @@ -0,0 +1,118 @@ +import { Transform } from "stream" +import { CancellationToken } from "builder-util-runtime" + +enum OperationKind { + COPY, + DOWNLOAD, +} + +export interface ProgressInfo { + total: number + delta: number + transferred: number + percent: number + bytesPerSecond: number +} + +export interface ProgressDifferentialDownloadInfo { + expectedByteCounts: Array + grandTotal: number +} + +export class ProgressDifferentialDownloadCallbackTransform extends Transform { + private start = Date.now() + private transferred = 0 + private delta = 0 + private expectedBytes = 0 + private index = 0 + private operationType = OperationKind.COPY + + private nextUpdate = this.start + 1000 + + constructor( + private readonly progressDifferentialDownloadInfo: ProgressDifferentialDownloadInfo, + private readonly cancellationToken: CancellationToken, + private readonly onProgress: (info: ProgressInfo) => any + ) { + super() + } + + _transform(chunk: any, encoding: string, callback: any) { + if (this.cancellationToken.cancelled) { + callback(new Error("cancelled"), null) + return + } + + // Don't send progress update when copying from disk + if (this.operationType == OperationKind.COPY) { + callback(null, chunk) + return + } + + this.transferred += chunk.length + this.delta += chunk.length + + const now = Date.now() + if ( + now >= this.nextUpdate && + this.transferred !== this.expectedBytes /* will be emitted by endRangeDownload() */ && + this.transferred !== this.progressDifferentialDownloadInfo.grandTotal /* will be emitted on _flush */ + ) { + this.nextUpdate = now + 1000 + + this.onProgress({ + total: this.progressDifferentialDownloadInfo.grandTotal, + delta: this.delta, + transferred: this.transferred, + percent: (this.transferred / this.progressDifferentialDownloadInfo.grandTotal) * 100, + bytesPerSecond: Math.round(this.transferred / ((now - this.start) / 1000)), + }) + this.delta = 0 + } + + callback(null, chunk) + } + + beginFileCopy(): void { + this.operationType = OperationKind.COPY + } + + beginRangeDownload(): void { + this.operationType = OperationKind.DOWNLOAD + + this.expectedBytes += this.progressDifferentialDownloadInfo.expectedByteCounts[this.index++] + } + + endRangeDownload(): void { + // _flush() will doour final 100% + if (this.transferred !== this.progressDifferentialDownloadInfo.grandTotal) { + this.onProgress({ + total: this.progressDifferentialDownloadInfo.grandTotal, + delta: this.delta, + transferred: this.transferred, + percent: (this.transferred / this.progressDifferentialDownloadInfo.grandTotal) * 100, + bytesPerSecond: Math.round(this.transferred / ((Date.now() - this.start) / 1000)), + }) + } + } + + // Called when we are 100% done with the connection/download + _flush(callback: any): void { + if (this.cancellationToken.cancelled) { + callback(new Error("cancelled")) + return + } + + this.onProgress({ + total: this.progressDifferentialDownloadInfo.grandTotal, + delta: this.delta, + transferred: this.transferred, + percent: 100, + bytesPerSecond: Math.round(this.transferred / ((Date.now() - this.start) / 1000)), + }) + this.delta = 0 + this.transferred = 0 + + callback(null) + } +} diff --git a/src/main/electron-updater/differentialDownloader/downloadPlanBuilder.ts b/src/main/electron-updater/differentialDownloader/downloadPlanBuilder.ts new file mode 100644 index 0000000..c4d6f17 --- /dev/null +++ b/src/main/electron-updater/differentialDownloader/downloadPlanBuilder.ts @@ -0,0 +1,135 @@ +import { BlockMap, BlockMapFile } from "builder-util-runtime/out/blockMapApi" +import { Logger } from "../main" + +export enum OperationKind { + COPY, + DOWNLOAD, +} + +export interface Operation { + kind: OperationKind + + // inclusive + start: number + // exclusive + end: number + + // debug only + // oldBlocks: Array | null +} + +export function computeOperations(oldBlockMap: BlockMap, newBlockMap: BlockMap, logger: Logger): Array { + const nameToOldBlocks = buildBlockFileMap(oldBlockMap.files) + const nameToNewBlocks = buildBlockFileMap(newBlockMap.files) + + let lastOperation: Operation | null = null + + // for now only one file is supported in block map + const blockMapFile: { name: string; offset: number } = newBlockMap.files[0] + const operations: Array = [] + const name = blockMapFile.name + const oldEntry = nameToOldBlocks.get(name) + if (oldEntry == null) { + // new file (unrealistic case for now, because in any case both blockmap contain the only file named as "file") + throw new Error(`no file ${name} in old blockmap`) + } + + const newFile = nameToNewBlocks.get(name)! + let changedBlockCount = 0 + + const { checksumToOffset: checksumToOldOffset, checksumToOldSize } = buildChecksumMap(nameToOldBlocks.get(name)!, oldEntry.offset, logger) + + let newOffset = blockMapFile.offset + for (let i = 0; i < newFile.checksums.length; newOffset += newFile.sizes[i], i++) { + const blockSize: number = newFile.sizes[i] + const checksum = newFile.checksums[i] + let oldOffset = checksumToOldOffset.get(checksum) + if (oldOffset != null && checksumToOldSize.get(checksum) !== blockSize) { + logger.warn(`Checksum ("${checksum}") matches, but size differs (old: ${checksumToOldSize.get(checksum)}, new: ${blockSize})`) + oldOffset = undefined + } + + if (oldOffset === undefined) { + // download data from new file + changedBlockCount++ + + if (lastOperation != null && lastOperation.kind === OperationKind.DOWNLOAD && lastOperation.end === newOffset) { + lastOperation.end += blockSize + } else { + lastOperation = { + kind: OperationKind.DOWNLOAD, + start: newOffset, + end: newOffset + blockSize, + // oldBlocks: null, + } + validateAndAdd(lastOperation, operations, checksum, i) + } + } else { + // reuse data from old file + if (lastOperation != null && lastOperation.kind === OperationKind.COPY && lastOperation.end === oldOffset) { + lastOperation.end += blockSize + // lastOperation.oldBlocks!!.push(checksum) + } else { + lastOperation = { + kind: OperationKind.COPY, + start: oldOffset, + end: oldOffset + blockSize, + // oldBlocks: [checksum] + } + validateAndAdd(lastOperation, operations, checksum, i) + } + } + } + + if (changedBlockCount > 0) { + logger.info(`File${blockMapFile.name === "file" ? "" : " " + blockMapFile.name} has ${changedBlockCount} changed blocks`) + } + return operations +} + +const isValidateOperationRange = process.env["DIFFERENTIAL_DOWNLOAD_PLAN_BUILDER_VALIDATE_RANGES"] === "true" + +function validateAndAdd(operation: Operation, operations: Array, checksum: string, index: number): void { + if (isValidateOperationRange && operations.length !== 0) { + const lastOperation = operations[operations.length - 1] + if (lastOperation.kind === operation.kind && operation.start < lastOperation.end && operation.start > lastOperation.start) { + const min = [lastOperation.start, lastOperation.end, operation.start, operation.end].reduce((p, v) => (p < v ? p : v)) + throw new Error( + `operation (block index: ${index}, checksum: ${checksum}, kind: ${OperationKind[operation.kind]}) overlaps previous operation (checksum: ${checksum}):\n` + + `abs: ${lastOperation.start} until ${lastOperation.end} and ${operation.start} until ${operation.end}\n` + + `rel: ${lastOperation.start - min} until ${lastOperation.end - min} and ${operation.start - min} until ${operation.end - min}` + ) + } + } + operations.push(operation) +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function buildChecksumMap(file: BlockMapFile, fileOffset: number, logger: Logger) { + const checksumToOffset = new Map() + const checksumToSize = new Map() + let offset = fileOffset + for (let i = 0; i < file.checksums.length; i++) { + const checksum = file.checksums[i] + const size = file.sizes[i] + + const existing = checksumToSize.get(checksum) + if (existing === undefined) { + checksumToOffset.set(checksum, offset) + checksumToSize.set(checksum, size) + } else if (logger.debug != null) { + const sizeExplanation = existing === size ? "(same size)" : `(size: ${existing}, this size: ${size})` + logger.debug(`${checksum} duplicated in blockmap ${sizeExplanation}, it doesn't lead to broken differential downloader, just corresponding block will be skipped)`) + } + offset += size + } + return { checksumToOffset, checksumToOldSize: checksumToSize } +} + +function buildBlockFileMap(list: Array): Map { + const result = new Map() + for (const item of list) { + result.set(item.name, item) + } + return result +} diff --git a/src/main/electron-updater/differentialDownloader/multipleRangeDownloader.ts b/src/main/electron-updater/differentialDownloader/multipleRangeDownloader.ts new file mode 100644 index 0000000..c8973c8 --- /dev/null +++ b/src/main/electron-updater/differentialDownloader/multipleRangeDownloader.ts @@ -0,0 +1,134 @@ +import { createHttpError, safeGetHeader } from "builder-util-runtime" +import { IncomingMessage } from "http" +import { Writable } from "stream" +import { copyData, DataSplitter, PartListDataTask } from "./DataSplitter" +import { DifferentialDownloader } from "./DifferentialDownloader" +import { Operation, OperationKind } from "./downloadPlanBuilder" + +export function executeTasksUsingMultipleRangeRequests( + differentialDownloader: DifferentialDownloader, + tasks: Array, + out: Writable, + oldFileFd: number, + reject: (error: Error) => void +): (taskOffset: number) => void { + const w = (taskOffset: number): void => { + if (taskOffset >= tasks.length) { + if (differentialDownloader.fileMetadataBuffer != null) { + out.write(differentialDownloader.fileMetadataBuffer) + } + out.end() + return + } + + const nextOffset = taskOffset + 1000 + doExecuteTasks( + differentialDownloader, + { + tasks, + start: taskOffset, + end: Math.min(tasks.length, nextOffset), + oldFileFd, + }, + out, + () => w(nextOffset), + reject + ) + } + return w +} + +function doExecuteTasks(differentialDownloader: DifferentialDownloader, options: PartListDataTask, out: Writable, resolve: () => void, reject: (error: Error) => void): void { + let ranges = "bytes=" + let partCount = 0 + const partIndexToTaskIndex = new Map() + const partIndexToLength: Array = [] + for (let i = options.start; i < options.end; i++) { + const task = options.tasks[i] + if (task.kind === OperationKind.DOWNLOAD) { + ranges += `${task.start}-${task.end - 1}, ` + partIndexToTaskIndex.set(partCount, i) + partCount++ + partIndexToLength.push(task.end - task.start) + } + } + + if (partCount <= 1) { + // the only remote range - copy + const w = (index: number): void => { + if (index >= options.end) { + resolve() + return + } + + const task = options.tasks[index++] + + if (task.kind === OperationKind.COPY) { + copyData(task, out, options.oldFileFd, reject, () => w(index)) + } else { + const requestOptions = differentialDownloader.createRequestOptions() + requestOptions.headers!.Range = `bytes=${task.start}-${task.end - 1}` + const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => { + if (!checkIsRangesSupported(response, reject)) { + return + } + + response.pipe(out, { + end: false, + }) + response.once("end", () => w(index)) + }) + differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject) + request.end() + } + } + + w(options.start) + return + } + + const requestOptions = differentialDownloader.createRequestOptions() + requestOptions.headers!.Range = ranges.substring(0, ranges.length - 2) + const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => { + if (!checkIsRangesSupported(response, reject)) { + return + } + + const contentType = safeGetHeader(response, "content-type") + const m = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i.exec(contentType) + if (m == null) { + reject(new Error(`Content-Type "multipart/byteranges" is expected, but got "${contentType}"`)) + return + } + + const dicer = new DataSplitter(out, options, partIndexToTaskIndex, m[1] || m[2], partIndexToLength, resolve) + dicer.on("error", reject) + response.pipe(dicer) + + response.on("end", () => { + setTimeout(() => { + request.abort() + reject(new Error("Response ends without calling any handlers")) + }, 10000) + }) + }) + differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject) + request.end() +} + +export function checkIsRangesSupported(response: IncomingMessage, reject: (error: Error) => void): boolean { + // Electron net handles redirects automatically, our NodeJS test server doesn't use redirects - so, we don't check 3xx codes. + if (response.statusCode! >= 400) { + reject(createHttpError(response)) + return false + } + + if (response.statusCode !== 206) { + const acceptRanges = safeGetHeader(response, "accept-ranges") + if (acceptRanges == null || acceptRanges === "none") { + reject(new Error(`Server doesn't support Accept-Ranges (response code ${response.statusCode})`)) + return false + } + } + return true +} diff --git a/src/main/electron-updater/electronHttpExecutor.ts b/src/main/electron-updater/electronHttpExecutor.ts new file mode 100644 index 0000000..f60af8e --- /dev/null +++ b/src/main/electron-updater/electronHttpExecutor.ts @@ -0,0 +1,95 @@ +import { DownloadOptions, HttpExecutor, configureRequestOptions, configureRequestUrl } from "builder-util-runtime" +import { AuthInfo } from "electron" +import { RequestOptions } from "http" +import Session = Electron.Session +import ClientRequest = Electron.ClientRequest + +export type LoginCallback = (username: string, password: string) => void +export const NET_SESSION_NAME = "electron-updater" + +export function getNetSession(): Session { + return require("electron").session.fromPartition(NET_SESSION_NAME, { + cache: false, + }) +} + +export class ElectronHttpExecutor extends HttpExecutor { + private cachedSession: Session | null = null + + constructor(private readonly proxyLoginCallback?: (authInfo: AuthInfo, callback: LoginCallback) => void) { + super() + } + + async download(url: URL, destination: string, options: DownloadOptions): Promise { + return await options.cancellationToken.createPromise((resolve, reject, onCancel) => { + const requestOptions = { + headers: options.headers || undefined, + redirect: "manual", + } + configureRequestUrl(url, requestOptions) + configureRequestOptions(requestOptions) + this.doDownload( + requestOptions, + { + destination, + options, + onCancel, + callback: error => { + if (error == null) { + resolve(destination) + } else { + reject(error) + } + }, + responseHandler: null, + }, + 0 + ) + }) + } + + createRequest(options: any, callback: (response: any) => void): Electron.ClientRequest { + // fix (node 7+) for making electron updater work when using AWS private buckets, check if headers contain Host property + if (options.headers && options.headers.Host) { + // set host value from headers.Host + options.host = options.headers.Host + // remove header property 'Host', if not removed causes net::ERR_INVALID_ARGUMENT exception + delete options.headers.Host + } + + // differential downloader can call this method very often, so, better to cache session + if (this.cachedSession == null) { + this.cachedSession = getNetSession() + } + + const request = require("electron").net.request({ + ...options, + session: this.cachedSession, + }) as Electron.ClientRequest + request.on("response", callback) + if (this.proxyLoginCallback != null) { + request.on("login", this.proxyLoginCallback) + } + return request + } + + protected addRedirectHandlers( + request: ClientRequest, + options: RequestOptions, + reject: (error: Error) => void, + redirectCount: number, + handler: (options: RequestOptions) => void + ): void { + request.on("redirect", (statusCode: number, method: string, redirectUrl: string) => { + // no way to modify request options, abort old and make a new one + // https://github.com/electron/electron/issues/11505 + request.abort() + + if (redirectCount > this.maxRedirects) { + reject(this.createMaxRedirectError()) + } else { + handler(HttpExecutor.prepareRedirectUrlOptions(redirectUrl, options)) + } + }) + } +} diff --git a/src/main/electron-updater/main.ts b/src/main/electron-updater/main.ts new file mode 100644 index 0000000..7b7e8d5 --- /dev/null +++ b/src/main/electron-updater/main.ts @@ -0,0 +1,146 @@ +import { CancellationToken, PackageFileInfo, ProgressInfo, UpdateFileInfo, UpdateInfo } from "builder-util-runtime" +import { EventEmitter } from "events" +import { existsSync, readFileSync } from "fs-extra" +import * as path from "path" +import { URL } from "url" +import { AppUpdater } from "./AppUpdater" +import { LoginCallback } from "./electronHttpExecutor" + +export { BaseUpdater } from "./BaseUpdater" +export { AppUpdater, NoOpLogger } from "./AppUpdater" +export { CancellationToken, PackageFileInfo, ProgressInfo, UpdateFileInfo, UpdateInfo } +export { Provider } from "./providers/Provider" +export { AppImageUpdater } from "./AppImageUpdater" +export { DebUpdater } from "./DebUpdater" +export { RpmUpdater } from "./RpmUpdater" +export { MacUpdater } from "./MacUpdater" +export { NsisUpdater } from "./NsisUpdater" + +// autoUpdater to mimic electron bundled autoUpdater +let _autoUpdater: any + +// required for jsdoc +export declare const autoUpdater: AppUpdater + +function doLoadAutoUpdater(): AppUpdater { + // tslint:disable:prefer-conditional-expression + if (process.platform === "win32") { + _autoUpdater = new (require("./NsisUpdater").NsisUpdater)() + } else if (process.platform === "darwin") { + _autoUpdater = new (require("./MacUpdater").MacUpdater)() + } else { + _autoUpdater = new (require("./AppImageUpdater").AppImageUpdater)() + try { + const identity = path.join(process.resourcesPath!, "package-type") + if (!existsSync(identity)) { + return _autoUpdater + } + console.info("Checking for beta autoupdate feature for deb/rpm distributions") + const fileType = readFileSync(identity).toString().trim() + console.info("Found package-type:", fileType) + switch (fileType) { + case "deb": + _autoUpdater = new (require("./DebUpdater").DebUpdater)() + break + case "rpm": + _autoUpdater = new (require("./RpmUpdater").RpmUpdater)() + break + default: + break + } + } catch (error: any) { + console.warn( + "Unable to detect 'package-type' for autoUpdater (beta rpm/deb support). If you'd like to expand support, please consider contributing to electron-builder", + error.message + ) + } + } + return _autoUpdater +} + +Object.defineProperty(exports, "autoUpdater", { + enumerable: true, + get: () => { + return _autoUpdater || doLoadAutoUpdater() + }, +}) + +export interface ResolvedUpdateFileInfo { + readonly url: URL + readonly info: UpdateFileInfo + + packageInfo?: PackageFileInfo +} + +export interface UpdateCheckResult { + readonly updateInfo: UpdateInfo + + readonly downloadPromise?: Promise> | null + + readonly cancellationToken?: CancellationToken + + /** @deprecated */ + readonly versionInfo: UpdateInfo +} + +export type UpdaterEvents = "login" | "checking-for-update" | "update-available" | "update-not-available" | "update-cancelled" | "download-progress" | "update-downloaded" | "error" + +export const DOWNLOAD_PROGRESS = "download-progress" +export const UPDATE_DOWNLOADED = "update-downloaded" + +export type LoginHandler = (authInfo: any, callback: LoginCallback) => void + +export class UpdaterSignal { + constructor(private emitter: EventEmitter) {} + + /** + * Emitted when an authenticating proxy is [asking for user credentials](https://github.com/electron/electron/blob/master/docs/api/client-request.md#event-login). + */ + login(handler: LoginHandler): void { + addHandler(this.emitter, "login", handler) + } + + progress(handler: (info: ProgressInfo) => void): void { + addHandler(this.emitter, DOWNLOAD_PROGRESS, handler) + } + + updateDownloaded(handler: (info: UpdateDownloadedEvent) => void): void { + addHandler(this.emitter, UPDATE_DOWNLOADED, handler) + } + + updateCancelled(handler: (info: UpdateInfo) => void): void { + addHandler(this.emitter, "update-cancelled", handler) + } +} + +export interface UpdateDownloadedEvent extends UpdateInfo { + downloadedFile: string +} + +const isLogEvent = false + +function addHandler(emitter: EventEmitter, event: UpdaterEvents, handler: (...args: Array) => void): void { + if (isLogEvent) { + emitter.on(event, (...args: Array) => { + console.log("%s %s", event, args) + handler(...args) + }) + } else { + emitter.on(event, handler) + } +} + +export interface Logger { + info(message?: any): void + + warn(message?: any): void + + error(message?: any): void + + debug?(message: string): void +} + +// return null if verify signature succeed +// return error message if verify signature failed + +export type verifyUpdateCodeSignature = (publisherName: string[], path: string) => Promise diff --git a/src/main/electron-updater/providerFactory.ts b/src/main/electron-updater/providerFactory.ts new file mode 100644 index 0000000..21f6b22 --- /dev/null +++ b/src/main/electron-updater/providerFactory.ts @@ -0,0 +1,85 @@ +import { + AllPublishOptions, + BaseS3Options, + BitbucketOptions, + CustomPublishOptions, + GenericServerOptions, + getS3LikeProviderBaseUrl, + GithubOptions, + KeygenOptions, + newError, + PublishConfiguration, +} from "builder-util-runtime" +import { AppUpdater } from "./AppUpdater" +import { BitbucketProvider } from "./providers/BitbucketProvider" +import { GenericProvider } from "./providers/GenericProvider" +import { GitHubProvider } from "./providers/GitHubProvider" +import { KeygenProvider } from "./providers/KeygenProvider" +import { PrivateGitHubProvider } from "./providers/PrivateGitHubProvider" +import { Provider, ProviderRuntimeOptions } from "./providers/Provider" + +export function isUrlProbablySupportMultiRangeRequests(url: string): boolean { + return !url.includes("s3.amazonaws.com") +} + +export function createClient(data: PublishConfiguration | AllPublishOptions, updater: AppUpdater, runtimeOptions: ProviderRuntimeOptions): Provider { + // noinspection SuspiciousTypeOfGuard + if (typeof data === "string") { + throw newError("Please pass PublishConfiguration object", "ERR_UPDATER_INVALID_PROVIDER_CONFIGURATION") + } + + const provider = data.provider + switch (provider) { + case "github": { + const githubOptions = data as GithubOptions + const token = (githubOptions.private ? process.env["GH_TOKEN"] || process.env["GITHUB_TOKEN"] : null) || githubOptions.token + if (token == null) { + return new GitHubProvider(githubOptions, updater, runtimeOptions) + } else { + return new PrivateGitHubProvider(githubOptions, updater, token, runtimeOptions) + } + } + + case "bitbucket": + return new BitbucketProvider(data as BitbucketOptions, updater, runtimeOptions) + + case "keygen": + return new KeygenProvider(data as KeygenOptions, updater, runtimeOptions) + + case "s3": + case "spaces": + return new GenericProvider( + { + provider: "generic", + url: getS3LikeProviderBaseUrl(data), + channel: (data as BaseS3Options).channel || null, + }, + updater, + { + ...runtimeOptions, + // https://github.com/minio/minio/issues/5285#issuecomment-350428955 + isUseMultipleRangeRequest: false, + } + ) + + case "generic": { + const options = data as GenericServerOptions + return new GenericProvider(options, updater, { + ...runtimeOptions, + isUseMultipleRangeRequest: options.useMultipleRangeRequest !== false && isUrlProbablySupportMultiRangeRequests(options.url), + }) + } + + case "custom": { + const options = data as CustomPublishOptions + const constructor = options.updateProvider + if (!constructor) { + throw newError("Custom provider not specified", "ERR_UPDATER_INVALID_PROVIDER_CONFIGURATION") + } + return new constructor(options, updater, runtimeOptions) + } + + default: + throw newError(`Unsupported provider: ${provider}`, "ERR_UPDATER_UNSUPPORTED_PROVIDER") + } +} diff --git a/src/main/electron-updater/providers/BitbucketProvider.ts b/src/main/electron-updater/providers/BitbucketProvider.ts new file mode 100644 index 0000000..2ff2063 --- /dev/null +++ b/src/main/electron-updater/providers/BitbucketProvider.ts @@ -0,0 +1,47 @@ +import { CancellationToken, BitbucketOptions, newError, UpdateInfo } from "builder-util-runtime" +import { AppUpdater } from "../AppUpdater" +import { ResolvedUpdateFileInfo } from "../main" +import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" +import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" + +export class BitbucketProvider extends Provider { + private readonly baseUrl: URL + + constructor( + private readonly configuration: BitbucketOptions, + private readonly updater: AppUpdater, + runtimeOptions: ProviderRuntimeOptions + ) { + super({ + ...runtimeOptions, + isUseMultipleRangeRequest: false, + }) + const { owner, slug } = configuration + this.baseUrl = newBaseUrl(`https://api.bitbucket.org/2.0/repositories/${owner}/${slug}/downloads`) + } + + private get channel(): string { + return this.updater.channel || this.configuration.channel || "latest" + } + + async getLatestVersion(): Promise { + const cancellationToken = new CancellationToken() + const channelFile = getChannelFilename(this.getCustomChannelName(this.channel)) + const channelUrl = newUrlFromBase(channelFile, this.baseUrl, this.updater.isAddNoCacheQuery) + try { + const updateInfo = await this.httpRequest(channelUrl, undefined, cancellationToken) + return parseUpdateInfo(updateInfo, channelFile, channelUrl) + } catch (e: any) { + throw newError(`Unable to find latest version on ${this.toString()}, please ensure release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND") + } + } + + resolveFiles(updateInfo: UpdateInfo): Array { + return resolveFiles(updateInfo, this.baseUrl) + } + + toString() { + const { owner, slug } = this.configuration + return `Bitbucket (owner: ${owner}, slug: ${slug}, channel: ${this.channel})` + } +} diff --git a/src/main/electron-updater/providers/GenericProvider.ts b/src/main/electron-updater/providers/GenericProvider.ts new file mode 100644 index 0000000..aa1dbed --- /dev/null +++ b/src/main/electron-updater/providers/GenericProvider.ts @@ -0,0 +1,52 @@ +import { GenericServerOptions, HttpError, newError, UpdateInfo } from "builder-util-runtime" +import { AppUpdater } from "../AppUpdater" +import { ResolvedUpdateFileInfo } from "../main" +import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" +import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" + +export class GenericProvider extends Provider { + private readonly baseUrl = newBaseUrl(this.configuration.url) + + constructor( + private readonly configuration: GenericServerOptions, + private readonly updater: AppUpdater, + runtimeOptions: ProviderRuntimeOptions + ) { + super(runtimeOptions) + } + + private get channel(): string { + const result = this.updater.channel || this.configuration.channel + return result == null ? this.getDefaultChannelName() : this.getCustomChannelName(result) + } + + async getLatestVersion(): Promise { + const channelFile = getChannelFilename(this.channel) + const channelUrl = newUrlFromBase(channelFile, this.baseUrl, this.updater.isAddNoCacheQuery) + for (let attemptNumber = 0; ; attemptNumber++) { + try { + return parseUpdateInfo(await this.httpRequest(channelUrl), channelFile, channelUrl) + } catch (e: any) { + if (e instanceof HttpError && e.statusCode === 404) { + throw newError(`Cannot find channel "${channelFile}" update info: ${e.stack || e.message}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND") + } else if (e.code === "ECONNREFUSED") { + if (attemptNumber < 3) { + await new Promise((resolve, reject) => { + try { + setTimeout(resolve, 1000 * attemptNumber) + } catch (e: any) { + reject(e) + } + }) + continue + } + } + throw e + } + } + } + + resolveFiles(updateInfo: UpdateInfo): Array { + return resolveFiles(updateInfo, this.baseUrl) + } +} diff --git a/src/main/electron-updater/providers/GitHubProvider.ts b/src/main/electron-updater/providers/GitHubProvider.ts new file mode 100644 index 0000000..1b15b6f --- /dev/null +++ b/src/main/electron-updater/providers/GitHubProvider.ts @@ -0,0 +1,225 @@ +import { CancellationToken, GithubOptions, githubUrl, HttpError, newError, parseXml, ReleaseNoteInfo, UpdateInfo, XElement } from "builder-util-runtime" +import * as semver from "semver" +import { URL } from "url" +import { AppUpdater } from "../AppUpdater" +import { ResolvedUpdateFileInfo } from "../main" +import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" +import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" + +const hrefRegExp = /\/tag\/([^/]+)$/ + +interface GithubUpdateInfo extends UpdateInfo { + tag: string +} +export abstract class BaseGitHubProvider extends Provider { + // so, we don't need to parse port (because node http doesn't support host as url does) + protected readonly baseUrl: URL + protected readonly baseApiUrl: URL + + protected constructor( + protected readonly options: GithubOptions, + defaultHost: string, + runtimeOptions: ProviderRuntimeOptions + ) { + super({ + ...runtimeOptions, + /* because GitHib uses S3 */ + isUseMultipleRangeRequest: false, + }) + + this.baseUrl = newBaseUrl(githubUrl(options, defaultHost)) + const apiHost = defaultHost === "github.com" ? "api.github.com" : defaultHost + this.baseApiUrl = newBaseUrl(githubUrl(options, apiHost)) + } + + protected computeGithubBasePath(result: string): string { + // https://github.com/electron-userland/electron-builder/issues/1903#issuecomment-320881211 + const host = this.options.host + return host && !["github.com", "api.github.com"].includes(host) ? `/api/v3${result}` : result + } +} + +export class GitHubProvider extends BaseGitHubProvider { + constructor( + protected readonly options: GithubOptions, + private readonly updater: AppUpdater, + runtimeOptions: ProviderRuntimeOptions + ) { + super(options, "github.com", runtimeOptions) + } + + async getLatestVersion(): Promise { + const cancellationToken = new CancellationToken() + + const feedXml: string = (await this.httpRequest( + newUrlFromBase(`${this.basePath}.atom`, this.baseUrl), + { + accept: "application/xml, application/atom+xml, text/xml, */*", + }, + cancellationToken + ))! + + const feed = parseXml(feedXml) + // noinspection TypeScriptValidateJSTypes + let latestRelease = feed.element("entry", false, `No published versions on GitHub`) + let tag: string | null = null + try { + if (this.updater.allowPrerelease) { + const currentChannel = this.updater?.channel || (semver.prerelease(this.updater.currentVersion)?.[0] as string) || null + + if (currentChannel === null) { + // noinspection TypeScriptValidateJSTypes + tag = hrefRegExp.exec(latestRelease.element("link").attribute("href"))![1] + } else { + for (const element of feed.getElements("entry")) { + // noinspection TypeScriptValidateJSTypes + const hrefElement = hrefRegExp.exec(element.element("link").attribute("href"))! + + // If this is null then something is wrong and skip this release + if (hrefElement === null) continue + + // This Release's Tag + const hrefTag = hrefElement[1] + //Get Channel from this release's tag + const hrefChannel = (semver.prerelease(hrefTag)?.[0] as string) || null + + const shouldFetchVersion = !currentChannel || ["alpha", "beta"].includes(currentChannel) + const isCustomChannel = hrefChannel !== null && !["alpha", "beta"].includes(String(hrefChannel)) + // Allow moving from alpha to beta but not down + const channelMismatch = currentChannel === "beta" && hrefChannel === "alpha" + + if (shouldFetchVersion && !isCustomChannel && !channelMismatch) { + tag = hrefTag + break + } + + const isNextPreRelease = hrefChannel && hrefChannel === currentChannel + if (isNextPreRelease) { + tag = hrefTag + break + } + } + } + } else { + tag = await this.getLatestTagName(cancellationToken) + for (const element of feed.getElements("entry")) { + // noinspection TypeScriptValidateJSTypes + if (hrefRegExp.exec(element.element("link").attribute("href"))![1] === tag) { + latestRelease = element + break + } + } + } + } catch (e: any) { + throw newError(`Cannot parse releases feed: ${e.stack || e.message},\nXML:\n${feedXml}`, "ERR_UPDATER_INVALID_RELEASE_FEED") + } + + if (tag == null) { + throw newError(`No published versions on GitHub`, "ERR_UPDATER_NO_PUBLISHED_VERSIONS") + } + + let rawData: string + let channelFile = "" + let channelFileUrl: any = "" + const fetchData = async (channelName: string) => { + channelFile = getChannelFilename(channelName) + channelFileUrl = newUrlFromBase(this.getBaseDownloadPath(String(tag), channelFile), this.baseUrl) + const requestOptions = this.createRequestOptions(channelFileUrl) + try { + return (await this.executor.request(requestOptions, cancellationToken))! + } catch (e: any) { + if (e instanceof HttpError && e.statusCode === 404) { + throw newError(`Cannot find ${channelFile} in the latest release artifacts (${channelFileUrl}): ${e.stack || e.message}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND") + } + throw e + } + } + + try { + const channel = this.updater.allowPrerelease ? this.getCustomChannelName(String(semver.prerelease(tag)?.[0] || "latest")) : this.getDefaultChannelName() + rawData = await fetchData(channel) + } catch (e: any) { + if (this.updater.allowPrerelease) { + // Allow fallback to `latest.yml` + rawData = await fetchData(this.getDefaultChannelName()) + } else { + throw e + } + } + + const result = parseUpdateInfo(rawData, channelFile, channelFileUrl) + if (result.releaseName == null) { + result.releaseName = latestRelease.elementValueOrEmpty("title") + } + + if (result.releaseNotes == null) { + result.releaseNotes = computeReleaseNotes(this.updater.currentVersion, this.updater.fullChangelog, feed, latestRelease) + } + return { + tag: tag, + ...result, + } + } + + private async getLatestTagName(cancellationToken: CancellationToken): Promise { + const options = this.options + // do not use API for GitHub to avoid limit, only for custom host or GitHub Enterprise + const url = + options.host == null || options.host === "github.com" + ? newUrlFromBase(`${this.basePath}/latest`, this.baseUrl) + : new URL(`${this.computeGithubBasePath(`/repos/${options.owner}/${options.repo}/releases`)}/latest`, this.baseApiUrl) + try { + const rawData = await this.httpRequest(url, { Accept: "application/json" }, cancellationToken) + if (rawData == null) { + return null + } + + const releaseInfo: GithubReleaseInfo = JSON.parse(rawData) + return releaseInfo.tag_name + } catch (e: any) { + throw newError(`Unable to find latest version on GitHub (${url}), please ensure a production release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND") + } + } + + private get basePath(): string { + return `/${this.options.owner}/${this.options.repo}/releases` + } + + resolveFiles(updateInfo: GithubUpdateInfo): Array { + // still replace space to - due to backward compatibility + return resolveFiles(updateInfo, this.baseUrl, p => this.getBaseDownloadPath(updateInfo.tag, p.replace(/ /g, "-"))) + } + + private getBaseDownloadPath(tag: string, fileName: string): string { + return `${this.basePath}/download/${tag}/${fileName}` + } +} + +interface GithubReleaseInfo { + readonly tag_name: string +} + +function getNoteValue(parent: XElement): string { + const result = parent.elementValueOrEmpty("content") + // GitHub reports empty notes as No content. + return result === "No content." ? "" : result +} + +export function computeReleaseNotes(currentVersion: semver.SemVer, isFullChangelog: boolean, feed: XElement, latestRelease: any): string | Array | null { + if (!isFullChangelog) { + return getNoteValue(latestRelease) + } + + const releaseNotes: Array = [] + for (const release of feed.getElements("entry")) { + // noinspection TypeScriptValidateJSTypes + const versionRelease = /\/tag\/v?([^/]+)$/.exec(release.element("link").attribute("href"))![1] + if (semver.lt(currentVersion, versionRelease)) { + releaseNotes.push({ + version: versionRelease, + note: getNoteValue(release), + }) + } + } + return releaseNotes.sort((a, b) => semver.rcompare(a.version, b.version)) +} diff --git a/src/main/electron-updater/providers/KeygenProvider.ts b/src/main/electron-updater/providers/KeygenProvider.ts new file mode 100644 index 0000000..3e07fc3 --- /dev/null +++ b/src/main/electron-updater/providers/KeygenProvider.ts @@ -0,0 +1,53 @@ +import { CancellationToken, KeygenOptions, newError, UpdateInfo } from "builder-util-runtime" +import { AppUpdater } from "../AppUpdater" +import { ResolvedUpdateFileInfo } from "../main" +import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" +import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" + +export class KeygenProvider extends Provider { + private readonly baseUrl: URL + + constructor( + private readonly configuration: KeygenOptions, + private readonly updater: AppUpdater, + runtimeOptions: ProviderRuntimeOptions + ) { + super({ + ...runtimeOptions, + isUseMultipleRangeRequest: false, + }) + this.baseUrl = newBaseUrl(`https://api.keygen.sh/v1/accounts/${this.configuration.account}/artifacts?product=${this.configuration.product}`) + } + + private get channel(): string { + return this.updater.channel || this.configuration.channel || "stable" + } + + async getLatestVersion(): Promise { + const cancellationToken = new CancellationToken() + const channelFile = getChannelFilename(this.getCustomChannelName(this.channel)) + const channelUrl = newUrlFromBase(channelFile, this.baseUrl, this.updater.isAddNoCacheQuery) + try { + const updateInfo = await this.httpRequest( + channelUrl, + { + Accept: "application/vnd.api+json", + "Keygen-Version": "1.1", + }, + cancellationToken + ) + return parseUpdateInfo(updateInfo, channelFile, channelUrl) + } catch (e: any) { + throw newError(`Unable to find latest version on ${this.toString()}, please ensure release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND") + } + } + + resolveFiles(updateInfo: UpdateInfo): Array { + return resolveFiles(updateInfo, this.baseUrl) + } + + toString() { + const { account, product, platform } = this.configuration + return `Keygen (account: ${account}, product: ${product}, platform: ${platform}, channel: ${this.channel})` + } +} diff --git a/src/main/electron-updater/providers/PrivateGitHubProvider.ts b/src/main/electron-updater/providers/PrivateGitHubProvider.ts new file mode 100644 index 0000000..94558de --- /dev/null +++ b/src/main/electron-updater/providers/PrivateGitHubProvider.ts @@ -0,0 +1,119 @@ +import { CancellationToken, GithubOptions, HttpError, newError, UpdateInfo } from "builder-util-runtime" +import { OutgoingHttpHeaders, RequestOptions } from "http" +import { load } from "js-yaml" +import * as path from "path" +import { AppUpdater } from "../AppUpdater" +import { URL } from "url" +import { getChannelFilename, newUrlFromBase } from "../util" +import { BaseGitHubProvider } from "./GitHubProvider" +import { ResolvedUpdateFileInfo } from "../main" +import { getFileList, ProviderRuntimeOptions } from "./Provider" + +export interface PrivateGitHubUpdateInfo extends UpdateInfo { + assets: Array +} + +export class PrivateGitHubProvider extends BaseGitHubProvider { + constructor( + options: GithubOptions, + private readonly updater: AppUpdater, + private readonly token: string, + runtimeOptions: ProviderRuntimeOptions + ) { + super(options, "api.github.com", runtimeOptions) + } + + protected createRequestOptions(url: URL, headers?: OutgoingHttpHeaders | null): RequestOptions { + const result = super.createRequestOptions(url, headers) + ;(result as any).redirect = "manual" + return result + } + + async getLatestVersion(): Promise { + const cancellationToken = new CancellationToken() + const channelFile = getChannelFilename(this.getDefaultChannelName()) + + const releaseInfo = await this.getLatestVersionInfo(cancellationToken) + const asset = releaseInfo.assets.find(it => it.name === channelFile) + if (asset == null) { + // html_url must be always, but just to be sure + throw newError(`Cannot find ${channelFile} in the release ${releaseInfo.html_url || releaseInfo.name}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND") + } + + const url = new URL(asset.url) + let result: any + try { + result = load((await this.httpRequest(url, this.configureHeaders("application/octet-stream"), cancellationToken))!) + } catch (e: any) { + if (e instanceof HttpError && e.statusCode === 404) { + throw newError(`Cannot find ${channelFile} in the latest release artifacts (${url}): ${e.stack || e.message}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND") + } + throw e + } + + ;(result as PrivateGitHubUpdateInfo).assets = releaseInfo.assets + return result + } + + get fileExtraDownloadHeaders(): OutgoingHttpHeaders | null { + return this.configureHeaders("application/octet-stream") + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + private configureHeaders(accept: string) { + return { + accept, + authorization: `token ${this.token}`, + } + } + + private async getLatestVersionInfo(cancellationToken: CancellationToken): Promise { + const allowPrerelease = this.updater.allowPrerelease + let basePath = this.basePath + if (!allowPrerelease) { + basePath = `${basePath}/latest` + } + + const url = newUrlFromBase(basePath, this.baseUrl) + try { + const version = JSON.parse((await this.httpRequest(url, this.configureHeaders("application/vnd.github.v3+json"), cancellationToken))!) + if (allowPrerelease) { + return (version as Array<{ prerelease: boolean }>).find(it => it.prerelease) || version[0] + } else { + return version + } + } catch (e: any) { + throw newError(`Unable to find latest version on GitHub (${url}), please ensure a production release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND") + } + } + + private get basePath(): string { + return this.computeGithubBasePath(`/repos/${this.options.owner}/${this.options.repo}/releases`) + } + + resolveFiles(updateInfo: PrivateGitHubUpdateInfo): Array { + return getFileList(updateInfo).map(it => { + const name = path.posix.basename(it.url).replace(/ /g, "-") + const asset = updateInfo.assets.find(it => it != null && it.name === name) + if (asset == null) { + throw newError(`Cannot find asset "${name}" in: ${JSON.stringify(updateInfo.assets, null, 2)}`, "ERR_UPDATER_ASSET_NOT_FOUND") + } + + return { + url: new URL(asset.url), + info: it, + } + }) + } +} + +interface ReleaseInfo { + name: string + html_url: string + assets: Array +} + +export interface Asset { + name: string + url: string +} diff --git a/src/main/electron-updater/providers/Provider.ts b/src/main/electron-updater/providers/Provider.ts new file mode 100644 index 0000000..37f6502 --- /dev/null +++ b/src/main/electron-updater/providers/Provider.ts @@ -0,0 +1,157 @@ +import { CancellationToken, configureRequestUrl, newError, safeStringifyJson, UpdateFileInfo, UpdateInfo, WindowsUpdateInfo } from "builder-util-runtime" +import { OutgoingHttpHeaders, RequestOptions } from "http" +import { load } from "js-yaml" +import { URL } from "url" +import { ElectronHttpExecutor } from "../electronHttpExecutor" +import { ResolvedUpdateFileInfo } from "../main" +import { newUrlFromBase } from "../util" + +export type ProviderPlatform = "darwin" | "linux" | "win32" + +export interface ProviderRuntimeOptions { + isUseMultipleRangeRequest: boolean + platform: ProviderPlatform + + executor: ElectronHttpExecutor +} + +export abstract class Provider { + private requestHeaders: OutgoingHttpHeaders | null = null + protected readonly executor: ElectronHttpExecutor + + protected constructor(private readonly runtimeOptions: ProviderRuntimeOptions) { + this.executor = runtimeOptions.executor + } + + get isUseMultipleRangeRequest(): boolean { + return this.runtimeOptions.isUseMultipleRangeRequest !== false + } + + private getChannelFilePrefix(): string { + if (this.runtimeOptions.platform === "linux") { + const arch = process.env["TEST_UPDATER_ARCH"] || process.arch + const archSuffix = arch === "x64" ? "" : `-${arch}` + return "-linux" + archSuffix + } else { + return this.runtimeOptions.platform === "darwin" ? "-mac" : "" + } + } + + // due to historical reasons for windows we use channel name without platform specifier + protected getDefaultChannelName(): string { + return this.getCustomChannelName("latest") + } + + protected getCustomChannelName(channel: string): string { + return `${channel}${this.getChannelFilePrefix()}` + } + + get fileExtraDownloadHeaders(): OutgoingHttpHeaders | null { + return null + } + + setRequestHeaders(value: OutgoingHttpHeaders | null): void { + this.requestHeaders = value + } + + abstract getLatestVersion(): Promise + + abstract resolveFiles(updateInfo: T): Array + + /** + * Method to perform API request only to resolve update info, but not to download update. + */ + protected httpRequest(url: URL, headers?: OutgoingHttpHeaders | null, cancellationToken?: CancellationToken): Promise { + return this.executor.request(this.createRequestOptions(url, headers), cancellationToken) + } + + protected createRequestOptions(url: URL, headers?: OutgoingHttpHeaders | null): RequestOptions { + const result: RequestOptions = {} + if (this.requestHeaders == null) { + if (headers != null) { + result.headers = headers + } + } else { + result.headers = headers == null ? this.requestHeaders : { ...this.requestHeaders, ...headers } + } + + configureRequestUrl(url, result) + return result + } +} + +export function findFile(files: Array, extension: string, not?: Array): ResolvedUpdateFileInfo | null | undefined { + if (files.length === 0) { + throw newError("No files provided", "ERR_UPDATER_NO_FILES_PROVIDED") + } + + const result = files.find(it => it.url.pathname.toLowerCase().endsWith(`.${extension}`)) + if (result != null) { + return result + } else if (not == null) { + return files[0] + } else { + return files.find(fileInfo => !not.some(ext => fileInfo.url.pathname.toLowerCase().endsWith(`.${ext}`))) + } +} + +export function parseUpdateInfo(rawData: string | null, channelFile: string, channelFileUrl: URL): UpdateInfo { + if (rawData == null) { + throw newError(`Cannot parse update info from ${channelFile} in the latest release artifacts (${channelFileUrl}): rawData: null`, "ERR_UPDATER_INVALID_UPDATE_INFO") + } + + let result: UpdateInfo + try { + result = load(rawData) as UpdateInfo + } catch (e: any) { + throw newError( + `Cannot parse update info from ${channelFile} in the latest release artifacts (${channelFileUrl}): ${e.stack || e.message}, rawData: ${rawData}`, + "ERR_UPDATER_INVALID_UPDATE_INFO" + ) + } + return result +} + +export function getFileList(updateInfo: UpdateInfo): Array { + const files = updateInfo.files + if (files != null && files.length > 0) { + return files + } + + // noinspection JSDeprecatedSymbols + if (updateInfo.path != null) { + // noinspection JSDeprecatedSymbols + return [ + { + url: updateInfo.path, + sha2: (updateInfo as any).sha2, + sha512: updateInfo.sha512, + } as any, + ] + } else { + throw newError(`No files provided: ${safeStringifyJson(updateInfo)}`, "ERR_UPDATER_NO_FILES_PROVIDED") + } +} + +export function resolveFiles(updateInfo: UpdateInfo, baseUrl: URL, pathTransformer: (p: string) => string = (p: string): string => p): Array { + const files = getFileList(updateInfo) + const result: Array = files.map(fileInfo => { + if ((fileInfo as any).sha2 == null && fileInfo.sha512 == null) { + throw newError(`Update info doesn't contain nor sha256 neither sha512 checksum: ${safeStringifyJson(fileInfo)}`, "ERR_UPDATER_NO_CHECKSUM") + } + return { + url: newUrlFromBase(pathTransformer(fileInfo.url), baseUrl), + info: fileInfo, + } + }) + + const packages = (updateInfo as WindowsUpdateInfo).packages + const packageInfo = packages == null ? null : packages[process.arch] || packages.ia32 + if (packageInfo != null) { + ;(result[0] as any).packageInfo = { + ...packageInfo, + path: newUrlFromBase(pathTransformer(packageInfo.path), baseUrl).href, + } + } + return result +} diff --git a/src/main/electron-updater/util.ts b/src/main/electron-updater/util.ts new file mode 100644 index 0000000..2216e3a --- /dev/null +++ b/src/main/electron-updater/util.ts @@ -0,0 +1,37 @@ +// if baseUrl path doesn't ends with /, this path will be not prepended to passed pathname for new URL(input, base) +import { URL } from "url" +// @ts-ignore +import * as escapeRegExp from "lodash.escaperegexp" + +/** @internal */ +export function newBaseUrl(url: string): URL { + const result = new URL(url) + if (!result.pathname.endsWith("/")) { + result.pathname += "/" + } + return result +} + +// addRandomQueryToAvoidCaching is false by default because in most cases URL already contains version number, +// so, it makes sense only for Generic Provider for channel files +export function newUrlFromBase(pathname: string, baseUrl: URL, addRandomQueryToAvoidCaching = false): URL { + const result = new URL(pathname, baseUrl) + // search is not propagated (search is an empty string if not specified) + const search = baseUrl.search + if (search != null && search.length !== 0) { + result.search = search + } else if (addRandomQueryToAvoidCaching) { + result.search = `noCache=${Date.now().toString(32)}` + } + return result +} + +export function getChannelFilename(channel: string): string { + return `${channel}.yml` +} + +export function blockmapFiles(baseUrl: URL, oldVersion: string, newVersion: string): URL[] { + const newBlockMapUrl = newUrlFromBase(`${baseUrl.pathname}.blockmap`, baseUrl) + const oldBlockMapUrl = newUrlFromBase(`${baseUrl.pathname.replace(new RegExp(escapeRegExp(newVersion), "g"), oldVersion)}.blockmap`, baseUrl) + return [oldBlockMapUrl, newBlockMapUrl] +} diff --git a/src/main/electron-updater/windowsExecutableCodeSignatureVerifier.ts b/src/main/electron-updater/windowsExecutableCodeSignatureVerifier.ts new file mode 100644 index 0000000..ab52ef6 --- /dev/null +++ b/src/main/electron-updater/windowsExecutableCodeSignatureVerifier.ts @@ -0,0 +1,132 @@ +import { parseDn } from "builder-util-runtime" +import { execFile, execFileSync } from "child_process" +import * as os from "os" +import { Logger } from "./main" + +// $certificateInfo = (Get-AuthenticodeSignature 'xxx\yyy.exe' +// | where {$_.Status.Equals([System.Management.Automation.SignatureStatus]::Valid) -and $_.SignerCertificate.Subject.Contains("CN=siemens.com")}) +// | Out-String ; if ($certificateInfo) { exit 0 } else { exit 1 } +export function verifySignature(publisherNames: Array, unescapedTempUpdateFile: string, logger: Logger): Promise { + return new Promise((resolve, reject) => { + // Escape quotes and backticks in filenames to prevent user from breaking the + // arguments and perform a remote command injection. + // + // Consider example powershell command: + // ```powershell + // Get-AuthenticodeSignature 'C:\\path\\my-bad-';calc;'filename.exe' + // ``` + // The above would work expected and find the file name, however, it will also execute `;calc;` + // command and start the calculator app. + // + // From Powershell quoting rules: + // https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7 + // * Double quotes `"` are treated literally within single-quoted strings; + // * Single quotes can be escaped by doubling them: 'don''t' -> don't; + // + // Also note that at this point the file has already been written to the disk, thus we are + // guaranteed that the path will not contain any illegal characters like <>:"/\|?* + // https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file + const tempUpdateFile = unescapedTempUpdateFile.replace(/'/g, "''") + logger.info(`Verifying signature ${tempUpdateFile}`) + + // https://github.com/electron-userland/electron-builder/issues/2421 + // https://github.com/electron-userland/electron-builder/issues/2535 + // Resetting PSModulePath is necessary https://github.com/electron-userland/electron-builder/issues/7127 + execFile( + `set "PSModulePath="; chcp 65001 >NUL & powershell.exe`, + ["-NoProfile", "-NonInteractive", "-InputFormat", "None", "-Command", `"Get-AuthenticodeSignature -LiteralPath '${tempUpdateFile}' | ConvertTo-Json -Compress"`], + { + shell: true, + timeout: 20 * 1000, + }, + (error, stdout, stderr) => { + try { + if (error != null || stderr) { + handleError(logger, error, stderr, reject) + resolve(null) + return + } + const data = parseOut(stdout) + if (data.Status === 0) { + const subject = parseDn(data.SignerCertificate.Subject) + let match = false + for (const name of publisherNames) { + const dn = parseDn(name) + if (dn.size) { + // if we have a full DN, compare all values + const allKeys = Array.from(dn.keys()) + match = allKeys.every(key => { + return dn.get(key) === subject.get(key) + }) + } else if (name === subject.get("CN")!) { + logger.warn(`Signature validated using only CN ${name}. Please add your full Distinguished Name (DN) to publisherNames configuration`) + match = true + } + if (match) { + resolve(null) + return + } + } + } + + const result = `publisherNames: ${publisherNames.join(" | ")}, raw info: ` + JSON.stringify(data, (name, value) => (name === "RawData" ? undefined : value), 2) + logger.warn(`Sign verification failed, installer signed with incorrect certificate: ${result}`) + resolve(result) + } catch (e: any) { + handleError(logger, e, null, reject) + resolve(null) + return + } + } + ) + }) +} + +function parseOut(out: string): any { + const data = JSON.parse(out) + delete data.PrivateKey + delete data.IsOSBinary + delete data.SignatureType + const signerCertificate = data.SignerCertificate + if (signerCertificate != null) { + delete signerCertificate.Archived + delete signerCertificate.Extensions + delete signerCertificate.Handle + delete signerCertificate.HasPrivateKey + // duplicates data.SignerCertificate (contains RawData) + delete signerCertificate.SubjectName + } + delete data.Path + return data +} + +function handleError(logger: Logger, error: Error | null, stderr: string | null, reject: (reason: any) => void): void { + if (isOldWin6()) { + logger.warn( + `Cannot execute Get-AuthenticodeSignature: ${error || stderr}. Ignoring signature validation due to unsupported powershell version. Please upgrade to powershell 3 or higher.` + ) + return + } + + try { + execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "ConvertTo-Json test"], { timeout: 10 * 1000 } as any) + } catch (testError: any) { + logger.warn( + `Cannot execute ConvertTo-Json: ${testError.message}. Ignoring signature validation due to unsupported powershell version. Please upgrade to powershell 3 or higher.` + ) + return + } + + if (error != null) { + reject(error) + } + + if (stderr) { + reject(new Error(`Cannot execute Get-AuthenticodeSignature, stderr: ${stderr}. Failing signature validation due to unknown stderr.`)) + } +} + +function isOldWin6(): boolean { + const winVersion = os.release() + return winVersion.startsWith("6.") && !winVersion.startsWith("6.3") +} diff --git a/src/main/nn-auto-updater/custom.ts b/src/main/nn-auto-updater/custom.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/main/nn-auto-updater/githubReleases.ts b/src/main/nn-auto-updater/githubReleases.ts new file mode 100644 index 0000000..20da244 --- /dev/null +++ b/src/main/nn-auto-updater/githubReleases.ts @@ -0,0 +1,141 @@ +import { arch, platform } from 'node:process'; +import { app } from "electron"; +import type { AppUpdaterEvents } from './main'; +import { downloadFile } from '../downloadFile'; +import { getNNDirPath } from '../files'; +import { spawnSync } from 'node:child_process'; +import { setFullQuitForNextQuit } from '../main'; +import logger from '../logger'; +import path from 'node:path'; + +const repo = 'NiceNode/test-nice-node-updater'; +// const repo = 'NiceNode/nice-node'; + +let latestDownloadFilePath = ''; +export const getLatestVersion = async () => { + const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`); + const data = await res.json(); + // remove prefix 'v' from tag_name + return data.tag_name.replace(/^v/, ''); +} + +export const checkForUpdates = async (emit: (e: any, args?: any) => void) => { + // if latest version is greater than current version + // download + emit('checking-for-update'); + const currentVersion = app.getVersion(); + logger.info(`currentVersion: ${currentVersion}`); + const latestVersion = await getLatestVersion(); + logger.info(`latestVersion: ${latestVersion}`); + + // if no github releases, return error + if(latestVersion === undefined) { + emit('error', new Error('No github releases found')); + return + } + // if versions equal, return no update + if(currentVersion === latestVersion) { + emit('update-not-available'); + return; + } + if(currentVersion < latestVersion) { + emit('update-available'); + // download + const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`); + const data = await res.json(); + + // loop over data.assets, check asset.name and parse out the ones we want to show in the UI. + // get asset.browser_download_url + logger.info("Github releases api data: ", data); + let downloadUrl = ''; + let fileName = ''; + const arch = process.arch === 'x64' ? 'amd64' : 'arm64'; + const releaseNotes = data.body; + const releaseDate = data.published_at; + for(const val of data.assets) { + if(val.name.endsWith(`${arch}.deb`)) { + downloadUrl = val.browser_download_url; + logger.info(`val.url: ${val.url}`); + fileName = val.name; + logger.info(`val.name: ${val.name}`); + break; + } + }; + + if(! downloadUrl || !fileName) { + emit('error', new Error('No github release found matching this architecture')); + return; + } + + latestDownloadFilePath = await downloadFile(downloadUrl, getNNDirPath()); + //latestDownloadFilePath = path.join(latestDownloadFilePath, fileName); + logger.info(`latestDownloadFilePath: ${latestDownloadFilePath}`); + emit('update-downloaded', [{}, releaseNotes, fileName, releaseDate, downloadUrl]); + // install downloaded and quit + // { + // url: 'https://api.github.com/repos/NiceNode/test-nice-node-updater/releases/assets/162903620', + // id: 162903620, + // node_id: 'RA_kwDOLvIOVs4JtbZE', + // name: 'NiceNode-linux-arm64-5.4.1-alpha.zip', + // label: '', + // uploader: [Object], + // content_type: 'application/zip', + // state: 'uploaded', + // size: 136035837, + // download_count: 0, + // created_at: '2024-04-18T17:12:26Z', + // updated_at: '2024-04-18T17:12:29Z', + // browser_download_url: 'https://github.com/NiceNode/test-nice-node-updater/releases/download/v5.4.1-alpha/NiceNode-linux-arm64-5.4.1-alpha.zip' + // }, + return; + } + +} +const wrapSudo = () => { + const installComment = `"${app.name} would like to update"` + const sudo = spawnSyncLog("which gksudo || which kdesudo || which pkexec || which beesu") + const command = [sudo] + if (/kdesudo/i.test(sudo)) { + command.push("--comment", installComment) + command.push("-c") + } else if (/gksudo/i.test(sudo)) { + command.push("--message", installComment) + } else if (/pkexec/i.test(sudo)) { + command.push("--disable-internal-agent") + } + return command.join(" ") +} + +const spawnSyncLog = (cmd: string, args: string[] = [], env = {}): string => { + logger.info(`Executing: ${cmd} with args: ${args}`) + const response = spawnSync(cmd, args, { + env: { ...process.env, ...env }, + encoding: "utf-8", + shell: true, + }) + return response.stdout.trim() +} + +export const quitAndInstall = () => { + logger.info('quitAndInstall called') + // add quit handler, call quit, install in quitHandler + + // sudo dpkg -i /latestDownloadFilePath + const sudo = wrapSudo(); + logger.info(`sudo cmd ${sudo}`); + const wrapper = /pkexec/i.test(sudo) ? "" : `"` + const cmd = ["dpkg", "-i", latestDownloadFilePath, "||", "apt-get", "install", "-f", "-y"] + logger.info(`quitAndInstall cmd: ${cmd.join(" ")}`); + spawnSyncLog(sudo, [`${wrapper}/bin/bash`, "-c", `'${cmd.join(" ")}'${wrapper}`]) + //if (options.isForceRunAfter) { + logger.info('quitAndInstall install done. relaunching '); + setFullQuitForNextQuit(true); + app.relaunch(); + app.quit(); + //} + // app.restart(); +} + + +console.log("CACHE DIR (sessionData): ", app.getPath("sessionData")); +console.log("TEMP DIR: ", app.getPath("temp")); diff --git a/src/main/nn-auto-updater/main.ts b/src/main/nn-auto-updater/main.ts new file mode 100644 index 0000000..e0f203c --- /dev/null +++ b/src/main/nn-auto-updater/main.ts @@ -0,0 +1,82 @@ +import { checkForUpdates } from './../updater'; +import { autoUpdater as _autoUpdater, type AutoUpdater } from 'electron'; +import EventEmitter from 'node:events'; +import { TypedEmitter } from "tiny-typed-emitter" + +import { isLinux } from '../platform'; +import * as githubReleases from './githubReleases'; + +/** + * event Event + * releaseNotes string + * releaseName string + * releaseDate Date + * updateURL string + */ +type UpdateDownloadedData = [Event, string, string, Date, string]; + +export type AppUpdaterEvents = { + error: (error: Error, message?: string) => void + "checking-for-update": () => void + "update-not-available": () => void + "update-available": () => void + "update-downloaded": (...data: UpdateDownloadedData) => void + "download-progress": () => void + "update-cancelled": () => void +} + +export class nnAutoUpdater extends TypedEmitter implements AutoUpdater { + private readonly nativeUpdater: AutoUpdater = _autoUpdater; + + constructor() { + console.log("nnAutoUpdater constructor"); + super(); + } + + emitCallback(e: any): void { + console.log("emitCallback e = ", e); + this.emit(e) + } + + checkForUpdates(): void { + // custom logic for linux as the native autoUpdater does not support linux + console.log("nnAutoUpdater checkForUpdates called"); + if(isLinux()) { + console.log("nnAutoUpdater checkForUpdates in linux!"); + this.emit('checking-for-update'); + githubReleases.checkForUpdates(this.emitCallback.bind(this)); + } else { + this.nativeUpdater.checkForUpdates(); + } + } + getFeedURL(): string { + console.log("nnAutoUpdater getFeedURL called"); + if(isLinux()) { + console.log("nnAutoUpdater getFeedURL in linux!"); + throw new Error('Method not implemented.'); + // biome-ignore lint/style/noUselessElse: + } else { + return this.nativeUpdater.getFeedURL(); + } + } + quitAndInstall(): void { + console.log("nnAutoUpdater quitAndInstall called"); + if(isLinux()) { + console.log("nnAutoUpdater quitAndInstall in linux!"); + githubReleases.quitAndInstall(); + } else { + this.nativeUpdater.quitAndInstall(); + } + } + setFeedURL(options: Electron.FeedURLOptions): void { + console.log("nnAutoUpdater setFeedURL called"); + if(isLinux()) { + console.log("nnAutoUpdater setFeedURL in linux!"); + + } else { + this.nativeUpdater.setFeedURL(options); + } + } +} + +export const autoUpdater = new nnAutoUpdater(); diff --git a/src/main/updater.ts b/src/main/updater.ts index a6875aa..84398fc 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,7 +1,8 @@ import sleep from 'await-sleep'; -import { app, autoUpdater, dialog, type BrowserWindow } from 'electron'; +import { app, dialog, type BrowserWindow } from 'electron'; import { autoUpdateLogger } from './logger'; +import { autoUpdater } from './nn-auto-updater/main'; import { reportEvent } from './events'; import i18nMain from './i18nMain'; import { setFullQuitForNextQuit } from './main'; @@ -106,6 +107,8 @@ const initUpdateHandlers = (browserWindow: BrowserWindow) => { export const checkForUpdates = (notifyIfNoUpdateAvailable: boolean) => { logger.info(`updater.checkForUpdates set to: ${notifyIfNoUpdateAvailable}`); notifyUserIfNoUpdateAvailable = notifyIfNoUpdateAvailable; + // if linux + // call autoUpdater.checkForUpdates(); }; From aaa8519ac68d568d7fa8c2c9a1c38eb1bdec459c Mon Sep 17 00:00:00 2001 From: jgresham Date: Fri, 19 Apr 2024 16:51:09 -0700 Subject: [PATCH 08/11] deb and rpm updater split with package manager selection --- .../nn-auto-updater/findPackageManager.ts | 66 +++++++++++++++++++ src/main/nn-auto-updater/githubReleases.ts | 56 ++++++++++++++-- src/main/nn-auto-updater/main.ts | 10 +++ 3 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/main/nn-auto-updater/findPackageManager.ts diff --git a/src/main/nn-auto-updater/findPackageManager.ts b/src/main/nn-auto-updater/findPackageManager.ts new file mode 100644 index 0000000..31bb69a --- /dev/null +++ b/src/main/nn-auto-updater/findPackageManager.ts @@ -0,0 +1,66 @@ +import { exec as execCallback } from 'node:child_process'; +import { promisify } from 'node:util'; + +const exec = promisify(execCallback); + +export type PackageType = "deb" | "rpm"; +export type PackageManager = "dpkg" | "dnf" | "yum" | "zypper"; + +interface PackageManagerMap { + [key: string]: PackageManager; +} + +interface PackageManagerToTypeMap { + [key: string]: PackageType; +} + +const packageManagers: PackageManagerMap = { + "apt-get": "dpkg", // "deb (apt)", + dnf: "dnf", // "rpm (dnf)", + yum: "yum", // "rpm (yum)", + // pacman: "pacman", + zypper: "zypper", // "rpm (zypper)" +}; + +const packageTypes: PackageManagerToTypeMap = { + "apt-get": "deb", // "deb (apt)", + dnf: "rpm", // "rpm (dnf)", + yum: "rpm", // "rpm (yum)", + // pacman: "pacman", + zypper: "rpm", // "rpm (zypper)" +}; + +export const findPackageManager = async (): Promise => { + for (const pkgManager of Object.keys(packageManagers)) { + try { + const { stdout } = await exec(`command -v ${pkgManager}`); + if (stdout.trim()) { + return packageManagers[pkgManager]; + } + } catch (error) { + // Command not found, continue checking the next + } + } + // "Package manager not found."; + return null; +} + +export const findPackageType = async (): Promise => { + for (const pkgManager of Object.keys(packageManagers)) { + try { + const { stdout } = await exec(`command -v ${pkgManager}`); + if (stdout.trim()) { + return packageTypes[pkgManager]; + } + } catch (error) { + // Command not found, continue checking the next + } + } + // "Package manager not found."; + return null; +} + +// (async () => { +// const result = await findPackageManager(); +// console.log("+++++++++++++++++++++++++", result); +// })(); diff --git a/src/main/nn-auto-updater/githubReleases.ts b/src/main/nn-auto-updater/githubReleases.ts index 20da244..d259ac7 100644 --- a/src/main/nn-auto-updater/githubReleases.ts +++ b/src/main/nn-auto-updater/githubReleases.ts @@ -6,7 +6,8 @@ import { getNNDirPath } from '../files'; import { spawnSync } from 'node:child_process'; import { setFullQuitForNextQuit } from '../main'; import logger from '../logger'; -import path from 'node:path'; +import { type PackageManager, type PackageType, findPackageManager, findPackageType } from './findPackageManager'; +import { find } from 'highcharts'; const repo = 'NiceNode/test-nice-node-updater'; // const repo = 'NiceNode/nice-node'; @@ -19,6 +20,9 @@ export const getLatestVersion = async () => { return data.tag_name.replace(/^v/, ''); } +let localPackageType: PackageType | null = null; +let localPackageManager: PackageManager | null = null; + export const checkForUpdates = async (emit: (e: any, args?: any) => void) => { // if latest version is greater than current version // download @@ -44,16 +48,24 @@ export const checkForUpdates = async (emit: (e: any, args?: any) => void) => { const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`); const data = await res.json(); + if(localPackageType === null) { + localPackageType = await findPackageType(); + if(localPackageType === null) { + emit('error', new Error('Local supported package type not found. Only deb and rpm currently supported.')); + return; + } + } + logger.info(`Looking for release package type : ${localPackageType}`); + // loop over data.assets, check asset.name and parse out the ones we want to show in the UI. // get asset.browser_download_url - logger.info("Github releases api data: ", data); let downloadUrl = ''; let fileName = ''; const arch = process.arch === 'x64' ? 'amd64' : 'arm64'; const releaseNotes = data.body; const releaseDate = data.published_at; for(const val of data.assets) { - if(val.name.endsWith(`${arch}.deb`)) { + if(val.name.endsWith(`${arch}.${localPackageType}`)) { downloadUrl = val.browser_download_url; logger.info(`val.url: ${val.url}`); fileName = val.name; @@ -116,7 +128,7 @@ const spawnSyncLog = (cmd: string, args: string[] = [], env = {}): string => { return response.stdout.trim() } -export const quitAndInstall = () => { +export const quitAndInstall = async () => { logger.info('quitAndInstall called') // add quit handler, call quit, install in quitHandler @@ -124,7 +136,41 @@ export const quitAndInstall = () => { const sudo = wrapSudo(); logger.info(`sudo cmd ${sudo}`); const wrapper = /pkexec/i.test(sudo) ? "" : `"` - const cmd = ["dpkg", "-i", latestDownloadFilePath, "||", "apt-get", "install", "-f", "-y"] + if(localPackageManager === null) { + localPackageManager = await findPackageManager(); + if(localPackageManager === null) { + throw new Error('Local supported package manager not found. Only dpkg, yum, dnf and zypper currently supported.'); + } + } + logger.info(`Using release package manager : ${localPackageManager}`); + let cmd = null; + if(localPackageManager === 'dpkg') { + cmd = ["dpkg", "-i", latestDownloadFilePath, "||", "apt-get", "install", "-f", "-y"] + } else if(localPackageType === 'rpm') { + if(localPackageManager === 'dnf' || localPackageManager === 'yum') { + cmd = [localPackageManager, "-y", "remove", `'${app.name}'`, ";", localPackageManager, "-y", "install", latestDownloadFilePath] + } else { + // zypper + cmd = [ + localPackageManager, + "remove", + "-y", + `'${app.name}'`, + ";", + localPackageManager, + "clean", + "--all", + ";", + localPackageManager, + "--no-refresh", + "install", + "--allow-unsigned-rpm", + "-y", + "-f", + latestDownloadFilePath, + ] + } + } logger.info(`quitAndInstall cmd: ${cmd.join(" ")}`); spawnSyncLog(sudo, [`${wrapper}/bin/bash`, "-c", `'${cmd.join(" ")}'${wrapper}`]) //if (options.isForceRunAfter) { diff --git a/src/main/nn-auto-updater/main.ts b/src/main/nn-auto-updater/main.ts index e0f203c..c1f5427 100644 --- a/src/main/nn-auto-updater/main.ts +++ b/src/main/nn-auto-updater/main.ts @@ -5,6 +5,9 @@ import { TypedEmitter } from "tiny-typed-emitter" import { isLinux } from '../platform'; import * as githubReleases from './githubReleases'; +import { findPackageManager } from './findPackageManager'; + +findPackageManager(); /** * event Event @@ -25,11 +28,18 @@ export type AppUpdaterEvents = { "update-cancelled": () => void } +let numTimeCalledConstructor = 0; + export class nnAutoUpdater extends TypedEmitter implements AutoUpdater { private readonly nativeUpdater: AutoUpdater = _autoUpdater; + private customUpdater: AutoUpdater | null = null; constructor() { console.log("nnAutoUpdater constructor"); + numTimeCalledConstructor++; + if(numTimeCalledConstructor > 1) { + throw new Error("nnAutoUpdater constructor called more than once!"); + } super(); } From c220d053c968c30dba90d972cde01065e47dd331 Mon Sep 17 00:00:00 2001 From: jgresham Date: Fri, 19 Apr 2024 17:15:39 -0700 Subject: [PATCH 09/11] bump 5.5.1-alpha --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eec0a10..c5100b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nice-node", - "version": "5.4.0-alpha", + "version": "5.5.1-alpha", "description": "Run a node at home, the easy way.", "homepage": "https://nicenode.xyz", "productName": "NiceNode", From 60aab050c52af57109ae3d6d40839ba1223e6177 Mon Sep 17 00:00:00 2001 From: jgresham Date: Mon, 22 Apr 2024 10:34:07 -0700 Subject: [PATCH 10/11] add icons and common linux config to rpm. bump 5.5.2-alpha --- forge.config.ts | 50 +++++++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/forge.config.ts b/forge.config.ts index 83c970f..a171276 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -30,6 +30,28 @@ const packagerConfig: ForgePackagerOptions = { // ignore: [ /stories/, /__tests__/, /.storybook/, /storybook/, /storybook-static/ ], }; +const commonLinuxConfig = { + icon: { + scalable: path.resolve(iconDir, 'icon.svg'), + // scalable: './assets/icons/icon.svg', + '1024x1024': path.resolve(iconDir, '1024x1024.png'), + '512x512': path.resolve(iconDir, '512x512.png'), + '256x256': path.resolve(iconDir, '256x256.png'), + '128x128': path.resolve(iconDir, '128x128.png'), + '96x96': path.resolve(iconDir, '96x96.png'), + '64x64': path.resolve(iconDir, '64x64.png'), + '48x48': path.resolve(iconDir, '48x48.png'), + '32x32': path.resolve(iconDir, '32x32.png'), + '16x16': path.resolve(iconDir, '16x16.png'), + }, + executableName: 'nice-node', + productName: 'NiceNode', + productDescription: "By running a node you become part of a global movement to decentralize a world of information. Prevent leaking your personal data to third party nodes. Ensure access when you need it, and don't be censored. Decentralization starts with you. Voice your choice, help your peers.", + maintainer: "NiceNode LLC ", + categories: ['Utility', 'System', 'Network', 'Development'], + mimeType: ['application/x-nice-node', 'x-scheme-handler/nice-node'], +} + // skip signing & notarizing on local builds console.log("process.env.CI: ", process.env.CI); if(process.env.CI && process.env.NO_CODE_SIGNING !== 'true') { @@ -67,31 +89,15 @@ const config: ForgeConfig = { }), }, new MakerZIP({}), - new MakerRpm({}, ['linux']), + { + name: '@electron-forge/maker-rpm', + platforms: ['linux'], + config: commonLinuxConfig + }, { name: '@electron-forge/maker-deb', platforms: ['linux'], - config: { - icon: { - scalable: path.resolve(iconDir, 'icon.svg'), - // scalable: './assets/icons/icon.svg', - '1024x1024': path.resolve(iconDir, '1024x1024.png'), - '512x512': path.resolve(iconDir, '512x512.png'), - '256x256': path.resolve(iconDir, '256x256.png'), - '128x128': path.resolve(iconDir, '128x128.png'), - '96x96': path.resolve(iconDir, '96x96.png'), - '64x64': path.resolve(iconDir, '64x64.png'), - '48x48': path.resolve(iconDir, '48x48.png'), - '32x32': path.resolve(iconDir, '32x32.png'), - '16x16': path.resolve(iconDir, '16x16.png'), - }, - executableName: 'nice-node', - productName: 'NiceNode', - productDescription: "By running a node you become part of a global movement to decentralize a world of information. Prevent leaking your personal data to third party nodes. Ensure access when you need it, and don't be censored. Decentralization starts with you. Voice your choice, help your peers.", - maintainer: "NiceNode LLC ", - categories: ['Utility', 'System', 'Network', 'Development'], - mimeType: ['application/x-nice-node', 'x-scheme-handler/nice-node'], - } + config: commonLinuxConfig }, new MakerDMG({ background: './assets/dmg-background.tiff', From 420680f09dcf7dc052a59d82b4c1796c7c5ef669 Mon Sep 17 00:00:00 2001 From: jgresham Date: Mon, 22 Apr 2024 10:41:37 -0700 Subject: [PATCH 11/11] bump 5.5.2. remove electron-updater reference code --- package-lock.json | 4 +- package.json | 2 +- src/main/electron-updater/AppAdapter.ts | 46 - src/main/electron-updater/AppImageUpdater.ts | 115 --- src/main/electron-updater/AppUpdater.ts | 833 ------------------ src/main/electron-updater/BaseUpdater.ts | 161 ---- src/main/electron-updater/DebUpdater.ts | 41 - .../DownloadedUpdateHelper.ts | 199 ----- .../electron-updater/ElectronAppAdapter.ts | 46 - src/main/electron-updater/MacUpdater.ts | 275 ------ src/main/electron-updater/NsisUpdater.ts | 209 ----- src/main/electron-updater/RpmUpdater.ts | 66 -- .../differentialDownloader/DataSplitter.ts | 233 ----- .../DifferentialDownloader.ts | 320 ------- ...hEmbeddedBlockMapDifferentialDownloader.ts | 37 - .../GenericDifferentialDownloader.ts | 8 - ...ssDifferentialDownloadCallbackTransform.ts | 118 --- .../downloadPlanBuilder.ts | 135 --- .../multipleRangeDownloader.ts | 134 --- .../electron-updater/electronHttpExecutor.ts | 95 -- src/main/electron-updater/main.ts | 146 --- src/main/electron-updater/providerFactory.ts | 85 -- .../providers/BitbucketProvider.ts | 47 - .../providers/GenericProvider.ts | 52 -- .../providers/GitHubProvider.ts | 225 ----- .../providers/KeygenProvider.ts | 53 -- .../providers/PrivateGitHubProvider.ts | 119 --- .../electron-updater/providers/Provider.ts | 157 ---- src/main/electron-updater/util.ts | 37 - .../windowsExecutableCodeSignatureVerifier.ts | 132 --- src/main/nn-auto-updater/custom.ts | 0 31 files changed, 3 insertions(+), 4127 deletions(-) delete mode 100644 src/main/electron-updater/AppAdapter.ts delete mode 100644 src/main/electron-updater/AppImageUpdater.ts delete mode 100644 src/main/electron-updater/AppUpdater.ts delete mode 100644 src/main/electron-updater/BaseUpdater.ts delete mode 100644 src/main/electron-updater/DebUpdater.ts delete mode 100644 src/main/electron-updater/DownloadedUpdateHelper.ts delete mode 100644 src/main/electron-updater/ElectronAppAdapter.ts delete mode 100644 src/main/electron-updater/MacUpdater.ts delete mode 100644 src/main/electron-updater/NsisUpdater.ts delete mode 100644 src/main/electron-updater/RpmUpdater.ts delete mode 100644 src/main/electron-updater/differentialDownloader/DataSplitter.ts delete mode 100644 src/main/electron-updater/differentialDownloader/DifferentialDownloader.ts delete mode 100644 src/main/electron-updater/differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader.ts delete mode 100644 src/main/electron-updater/differentialDownloader/GenericDifferentialDownloader.ts delete mode 100644 src/main/electron-updater/differentialDownloader/ProgressDifferentialDownloadCallbackTransform.ts delete mode 100644 src/main/electron-updater/differentialDownloader/downloadPlanBuilder.ts delete mode 100644 src/main/electron-updater/differentialDownloader/multipleRangeDownloader.ts delete mode 100644 src/main/electron-updater/electronHttpExecutor.ts delete mode 100644 src/main/electron-updater/main.ts delete mode 100644 src/main/electron-updater/providerFactory.ts delete mode 100644 src/main/electron-updater/providers/BitbucketProvider.ts delete mode 100644 src/main/electron-updater/providers/GenericProvider.ts delete mode 100644 src/main/electron-updater/providers/GitHubProvider.ts delete mode 100644 src/main/electron-updater/providers/KeygenProvider.ts delete mode 100644 src/main/electron-updater/providers/PrivateGitHubProvider.ts delete mode 100644 src/main/electron-updater/providers/Provider.ts delete mode 100644 src/main/electron-updater/util.ts delete mode 100644 src/main/electron-updater/windowsExecutableCodeSignatureVerifier.ts delete mode 100644 src/main/nn-auto-updater/custom.ts diff --git a/package-lock.json b/package-lock.json index 6941ca6..3950dd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nice-node", - "version": "5.4.1-alpha", + "version": "5.5.2-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nice-node", - "version": "5.4.1-alpha", + "version": "5.5.2-alpha", "license": "MIT", "dependencies": { "@reduxjs/toolkit": "^1.9.3", diff --git a/package.json b/package.json index c5100b7..df5711f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nice-node", - "version": "5.5.1-alpha", + "version": "5.5.2-alpha", "description": "Run a node at home, the easy way.", "homepage": "https://nicenode.xyz", "productName": "NiceNode", diff --git a/src/main/electron-updater/AppAdapter.ts b/src/main/electron-updater/AppAdapter.ts deleted file mode 100644 index 69cc817..0000000 --- a/src/main/electron-updater/AppAdapter.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as path from "path" -import { homedir as getHomedir } from "os" - -export interface AppAdapter { - readonly version: string - readonly name: string - - readonly isPackaged: boolean - - /** - * Path to update metadata file. - */ - readonly appUpdateConfigPath: string - - /** - * Path to user data directory. - */ - readonly userDataPath: string - - /** - * Path to cache directory. - */ - readonly baseCachePath: string - - whenReady(): Promise - - relaunch(): void - - quit(): void - - onQuit(handler: (exitCode: number) => void): void -} - -export function getAppCacheDir() { - const homedir = getHomedir() - // https://github.com/electron/electron/issues/1404#issuecomment-194391247 - let result: string - if (process.platform === "win32") { - result = process.env["LOCALAPPDATA"] || path.join(homedir, "AppData", "Local") - } else if (process.platform === "darwin") { - result = path.join(homedir, "Library", "Caches") - } else { - result = process.env["XDG_CACHE_HOME"] || path.join(homedir, ".cache") - } - return result -} diff --git a/src/main/electron-updater/AppImageUpdater.ts b/src/main/electron-updater/AppImageUpdater.ts deleted file mode 100644 index a8cc747..0000000 --- a/src/main/electron-updater/AppImageUpdater.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { AllPublishOptions, newError } from "builder-util-runtime" -import { execFileSync } from "child_process" -import { chmod } from "fs-extra" -import { unlinkSync } from "fs" -import * as path from "path" -import { DownloadUpdateOptions } from "./AppUpdater" -import { BaseUpdater, InstallOptions } from "./BaseUpdater" -import { DifferentialDownloaderOptions } from "./differentialDownloader/DifferentialDownloader" -import { FileWithEmbeddedBlockMapDifferentialDownloader } from "./differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader" -import { DOWNLOAD_PROGRESS } from "./main" -import { findFile } from "./providers/Provider" - -export class AppImageUpdater extends BaseUpdater { - constructor(options?: AllPublishOptions | null, app?: any) { - super(options, app) - } - - public isUpdaterActive(): boolean { - if (process.env["APPIMAGE"] == null) { - if (process.env["SNAP"] == null) { - this._logger.warn("APPIMAGE env is not defined, current application is not an AppImage") - } else { - this._logger.info("SNAP env is defined, updater is disabled") - } - return false - } - return super.isUpdaterActive() - } - - /*** @private */ - protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { - const provider = downloadUpdateOptions.updateInfoAndProvider.provider - const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "AppImage", ["rpm", "deb"])! - return this.executeDownload({ - fileExtension: "AppImage", - fileInfo, - downloadUpdateOptions, - task: async (updateFile, downloadOptions) => { - const oldFile = process.env["APPIMAGE"]! - if (oldFile == null) { - throw newError("APPIMAGE env is not defined", "ERR_UPDATER_OLD_FILE_NOT_FOUND") - } - - let isDownloadFull = false - try { - const downloadOptions: DifferentialDownloaderOptions = { - newUrl: fileInfo.url, - oldFile, - logger: this._logger, - newFile: updateFile, - isUseMultipleRangeRequest: provider.isUseMultipleRangeRequest, - requestHeaders: downloadUpdateOptions.requestHeaders, - cancellationToken: downloadUpdateOptions.cancellationToken, - } - - if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { - downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) - } - - await new FileWithEmbeddedBlockMapDifferentialDownloader(fileInfo.info, this.httpExecutor, downloadOptions).download() - } catch (e: any) { - this._logger.error(`Cannot download differentially, fallback to full download: ${e.stack || e}`) - // during test (developer machine mac) we must throw error - isDownloadFull = process.platform === "linux" - } - - if (isDownloadFull) { - await this.httpExecutor.download(fileInfo.url, updateFile, downloadOptions) - } - - await chmod(updateFile, 0o755) - }, - }) - } - - protected doInstall(options: InstallOptions): boolean { - const appImageFile = process.env["APPIMAGE"]! - if (appImageFile == null) { - throw newError("APPIMAGE env is not defined", "ERR_UPDATER_OLD_FILE_NOT_FOUND") - } - - // https://stackoverflow.com/a/1712051/1910191 - unlinkSync(appImageFile) - - let destination: string - const existingBaseName = path.basename(appImageFile) - // https://github.com/electron-userland/electron-builder/issues/2964 - // if no version in existing file name, it means that user wants to preserve current custom name - if (path.basename(options.installerPath) === existingBaseName || !/\d+\.\d+\.\d+/.test(existingBaseName)) { - // no version in the file name, overwrite existing - destination = appImageFile - } else { - destination = path.join(path.dirname(appImageFile), path.basename(options.installerPath)) - } - - execFileSync("mv", ["-f", options.installerPath, destination]) - if (destination !== appImageFile) { - this.emit("appimage-filename-updated", destination) - } - - const env: NodeJS.ProcessEnv = { - ...process.env, - APPIMAGE_SILENT_INSTALL: "true", - } - - if (options.isForceRunAfter) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.spawnLog(destination, [], env) - } else { - env.APPIMAGE_EXIT_AFTER_INSTALL = "true" - execFileSync(destination, [], { env }) - } - return true - } -} diff --git a/src/main/electron-updater/AppUpdater.ts b/src/main/electron-updater/AppUpdater.ts deleted file mode 100644 index 0405b0c..0000000 --- a/src/main/electron-updater/AppUpdater.ts +++ /dev/null @@ -1,833 +0,0 @@ -import { - AllPublishOptions, - asArray, - CancellationToken, - newError, - PublishConfiguration, - UpdateInfo, - UUID, - DownloadOptions, - CancellationError, - ProgressInfo, - BlockMap, -} from "builder-util-runtime" -import { randomBytes } from "crypto" -import { release } from "os" -import { EventEmitter } from "events" -import { mkdir, outputFile, readFile, rename, unlink } from "fs-extra" -import { OutgoingHttpHeaders } from "http" -import { load } from "js-yaml" -import { Lazy } from "lazy-val" -import * as path from "path" -import { eq as isVersionsEqual, gt as isVersionGreaterThan, lt as isVersionLessThan, parse as parseVersion, prerelease as getVersionPreleaseComponents, SemVer } from "semver" -import { AppAdapter } from "./AppAdapter" -import { createTempUpdateFile, DownloadedUpdateHelper } from "./DownloadedUpdateHelper" -import { ElectronAppAdapter } from "./ElectronAppAdapter" -import { ElectronHttpExecutor, getNetSession, LoginCallback } from "./electronHttpExecutor" -import { GenericProvider } from "./providers/GenericProvider" -import { DOWNLOAD_PROGRESS, Logger, Provider, ResolvedUpdateFileInfo, UPDATE_DOWNLOADED, UpdateCheckResult, UpdateDownloadedEvent, UpdaterSignal } from "./main" -import { createClient, isUrlProbablySupportMultiRangeRequests } from "./providerFactory" -import { ProviderPlatform } from "./providers/Provider" -import type { TypedEmitter } from "tiny-typed-emitter" -import Session = Electron.Session -import { AuthInfo } from "electron" -import { gunzipSync } from "zlib" -import { blockmapFiles } from "./util" -import { DifferentialDownloaderOptions } from "./differentialDownloader/DifferentialDownloader" -import { GenericDifferentialDownloader } from "./differentialDownloader/GenericDifferentialDownloader" - -export type AppUpdaterEvents = { - error: (error: Error, message?: string) => void - login: (info: AuthInfo, callback: LoginCallback) => void - "checking-for-update": () => void - "update-not-available": (info: UpdateInfo) => void - "update-available": (info: UpdateInfo) => void - "update-downloaded": (event: UpdateDownloadedEvent) => void - "download-progress": (info: ProgressInfo) => void - "update-cancelled": (info: UpdateInfo) => void - "appimage-filename-updated": (path: string) => void -} - -export abstract class AppUpdater extends (EventEmitter as new () => TypedEmitter) { - /** - * Whether to automatically download an update when it is found. - */ - autoDownload = true - - /** - * Whether to automatically install a downloaded update on app quit (if `quitAndInstall` was not called before). - */ - autoInstallOnAppQuit = true - - /** - * *windows-only* Whether to run the app after finish install when run the installer NOT in silent mode. - * @default true - */ - autoRunAppAfterInstall = true - - /** - * *GitHub provider only.* Whether to allow update to pre-release versions. Defaults to `true` if application version contains prerelease components (e.g. `0.12.1-alpha.1`, here `alpha` is a prerelease component), otherwise `false`. - * - * If `true`, downgrade will be allowed (`allowDowngrade` will be set to `true`). - */ - allowPrerelease = false - - /** - * *GitHub provider only.* Get all release notes (from current version to latest), not just the latest. - * @default false - */ - fullChangelog = false - - /** - * Whether to allow version downgrade (when a user from the beta channel wants to go back to the stable channel). - * - * Taken in account only if channel differs (pre-release version component in terms of semantic versioning). - * - * @default false - */ - allowDowngrade = false - - /** - * Web installer files might not have signature verification, this switch prevents to load them unless it is needed. - * - * Currently false to prevent breaking the current API, but it should be changed to default true at some point that - * breaking changes are allowed. - * - * @default false - */ - disableWebInstaller = false - - /** - * *NSIS only* Disable differential downloads and always perform full download of installer. - * - * @default false - */ - disableDifferentialDownload = false - - /** - * Allows developer to force the updater to work in "dev" mode, looking for "dev-app-update.yml" instead of "app-update.yml" - * Dev: `path.join(this.app.getAppPath(), "dev-app-update.yml")` - * Prod: `path.join(process.resourcesPath!, "app-update.yml")` - * - * @default false - */ - forceDevUpdateConfig = false - - /** - * The current application version. - */ - readonly currentVersion: SemVer - - private _channel: string | null = null - - protected downloadedUpdateHelper: DownloadedUpdateHelper | null = null - - /** - * Get the update channel. Not applicable for GitHub. Doesn't return `channel` from the update configuration, only if was previously set. - */ - get channel(): string | null { - return this._channel - } - - /** - * Set the update channel. Not applicable for GitHub. Overrides `channel` in the update configuration. - * - * `allowDowngrade` will be automatically set to `true`. If this behavior is not suitable for you, simple set `allowDowngrade` explicitly after. - */ - set channel(value: string | null) { - if (this._channel != null) { - // noinspection SuspiciousTypeOfGuard - if (typeof value !== "string") { - throw newError(`Channel must be a string, but got: ${value}`, "ERR_UPDATER_INVALID_CHANNEL") - } else if (value.length === 0) { - throw newError(`Channel must be not an empty string`, "ERR_UPDATER_INVALID_CHANNEL") - } - } - - this._channel = value - this.allowDowngrade = true - } - - /** - * The request headers. - */ - requestHeaders: OutgoingHttpHeaders | null = null - - /** - * Shortcut for explicitly adding auth tokens to request headers - */ - addAuthHeader(token: string) { - this.requestHeaders = Object.assign({}, this.requestHeaders, { - authorization: token, - }) - } - - protected _logger: Logger = console - - // noinspection JSMethodCanBeStatic,JSUnusedGlobalSymbols - get netSession(): Session { - return getNetSession() - } - - /** - * The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`. - * Set it to `null` if you would like to disable a logging feature. - */ - get logger(): Logger | null { - return this._logger - } - - set logger(value: Logger | null) { - this._logger = value == null ? new NoOpLogger() : value - } - - // noinspection JSUnusedGlobalSymbols - /** - * For type safety you can use signals, e.g. `autoUpdater.signals.updateDownloaded(() => {})` instead of `autoUpdater.on('update-available', () => {})` - */ - readonly signals = new UpdaterSignal(this) - - private _appUpdateConfigPath: string | null = null - - // noinspection JSUnusedGlobalSymbols - /** - * test only - * @private - */ - set updateConfigPath(value: string | null) { - this.clientPromise = null - this._appUpdateConfigPath = value - this.configOnDisk = new Lazy(() => this.loadUpdateConfig()) - } - - private clientPromise: Promise> | null = null - - protected readonly stagingUserIdPromise = new Lazy(() => this.getOrCreateStagingUserId()) - - // public, allow to read old config for anyone - /** @internal */ - configOnDisk = new Lazy(() => this.loadUpdateConfig()) - - private checkForUpdatesPromise: Promise | null = null - - protected readonly app: AppAdapter - - protected updateInfoAndProvider: UpdateInfoAndProvider | null = null - - /** @internal */ - readonly httpExecutor: ElectronHttpExecutor - - protected constructor(options: AllPublishOptions | null | undefined, app?: AppAdapter) { - super() - - this.on("error", (error: Error) => { - this._logger.error(`Error: ${error.stack || error.message}`) - }) - - if (app == null) { - this.app = new ElectronAppAdapter() - this.httpExecutor = new ElectronHttpExecutor((authInfo, callback) => this.emit("login", authInfo, callback)) - } else { - this.app = app - this.httpExecutor = null as any - } - - const currentVersionString = this.app.version - const currentVersion = parseVersion(currentVersionString) - if (currentVersion == null) { - throw newError(`App version is not a valid semver version: "${currentVersionString}"`, "ERR_UPDATER_INVALID_VERSION") - } - this.currentVersion = currentVersion - this.allowPrerelease = hasPrereleaseComponents(currentVersion) - - if (options != null) { - this.setFeedURL(options) - - if (typeof options !== "string" && options.requestHeaders) { - this.requestHeaders = options.requestHeaders - } - } - } - - //noinspection JSMethodCanBeStatic,JSUnusedGlobalSymbols - getFeedURL(): string | null | undefined { - return "Deprecated. Do not use it." - } - - /** - * Configure update provider. If value is `string`, [GenericServerOptions](/configuration/publish#genericserveroptions) will be set with value as `url`. - * @param options If you want to override configuration in the `app-update.yml`. - */ - setFeedURL(options: PublishConfiguration | AllPublishOptions | string) { - const runtimeOptions = this.createProviderRuntimeOptions() - // https://github.com/electron-userland/electron-builder/issues/1105 - let provider: Provider - if (typeof options === "string") { - provider = new GenericProvider({ provider: "generic", url: options }, this, { - ...runtimeOptions, - isUseMultipleRangeRequest: isUrlProbablySupportMultiRangeRequests(options), - }) - } else { - provider = createClient(options, this, runtimeOptions) - } - this.clientPromise = Promise.resolve(provider) - } - - /** - * Asks the server whether there is an update. - */ - checkForUpdates(): Promise { - if (!this.isUpdaterActive()) { - return Promise.resolve(null) - } - - let checkForUpdatesPromise = this.checkForUpdatesPromise - if (checkForUpdatesPromise != null) { - this._logger.info("Checking for update (already in progress)") - return checkForUpdatesPromise - } - - const nullizePromise = () => (this.checkForUpdatesPromise = null) - - this._logger.info("Checking for update") - checkForUpdatesPromise = this.doCheckForUpdates() - .then(it => { - nullizePromise() - return it - }) - .catch((e: any) => { - nullizePromise() - this.emit("error", e, `Cannot check for updates: ${(e.stack || e).toString()}`) - throw e - }) - - this.checkForUpdatesPromise = checkForUpdatesPromise - return checkForUpdatesPromise - } - - public isUpdaterActive(): boolean { - const isEnabled = this.app.isPackaged || this.forceDevUpdateConfig - if (!isEnabled) { - this._logger.info("Skip checkForUpdates because application is not packed and dev update config is not forced") - return false - } - return true - } - - // noinspection JSUnusedGlobalSymbols - checkForUpdatesAndNotify(downloadNotification?: DownloadNotification): Promise { - return this.checkForUpdates().then(it => { - if (!it?.downloadPromise) { - if (this._logger.debug != null) { - this._logger.debug("checkForUpdatesAndNotify called, downloadPromise is null") - } - return it - } - - void it.downloadPromise.then(() => { - const notificationContent = AppUpdater.formatDownloadNotification(it.updateInfo.version, this.app.name, downloadNotification) - new (require("electron").Notification)(notificationContent).show() - }) - - return it - }) - } - - private static formatDownloadNotification(version: string, appName: string, downloadNotification?: DownloadNotification): DownloadNotification { - if (downloadNotification == null) { - downloadNotification = { - title: "A new update is ready to install", - body: `{appName} version {version} has been downloaded and will be automatically installed on exit`, - } - } - downloadNotification = { - title: downloadNotification.title.replace("{appName}", appName).replace("{version}", version), - body: downloadNotification.body.replace("{appName}", appName).replace("{version}", version), - } - return downloadNotification - } - - private async isStagingMatch(updateInfo: UpdateInfo): Promise { - const rawStagingPercentage = updateInfo.stagingPercentage - let stagingPercentage = rawStagingPercentage - if (stagingPercentage == null) { - return true - } - - stagingPercentage = parseInt(stagingPercentage as any, 10) - if (isNaN(stagingPercentage)) { - this._logger.warn(`Staging percentage is NaN: ${rawStagingPercentage}`) - return true - } - - // convert from user 0-100 to internal 0-1 - stagingPercentage = stagingPercentage / 100 - - const stagingUserId = await this.stagingUserIdPromise.value - const val = UUID.parse(stagingUserId).readUInt32BE(12) - const percentage = val / 0xffffffff - this._logger.info(`Staging percentage: ${stagingPercentage}, percentage: ${percentage}, user id: ${stagingUserId}`) - return percentage < stagingPercentage - } - - private computeFinalHeaders(headers: OutgoingHttpHeaders) { - if (this.requestHeaders != null) { - Object.assign(headers, this.requestHeaders) - } - return headers - } - - private async isUpdateAvailable(updateInfo: UpdateInfo): Promise { - const latestVersion = parseVersion(updateInfo.version) - if (latestVersion == null) { - throw newError( - `This file could not be downloaded, or the latest version (from update server) does not have a valid semver version: "${updateInfo.version}"`, - "ERR_UPDATER_INVALID_VERSION" - ) - } - - const currentVersion = this.currentVersion - if (isVersionsEqual(latestVersion, currentVersion)) { - return false - } - - const minimumSystemVersion = updateInfo?.minimumSystemVersion - const currentOSVersion = release() - if (minimumSystemVersion) { - try { - if (isVersionLessThan(currentOSVersion, minimumSystemVersion)) { - this._logger.info(`Current OS version ${currentOSVersion} is less than the minimum OS version required ${minimumSystemVersion} for version ${currentOSVersion}`) - return false - } - } catch (e: any) { - this._logger.warn(`Failed to compare current OS version(${currentOSVersion}) with minimum OS version(${minimumSystemVersion}): ${(e.message || e).toString()}`) - } - } - - const isStagingMatch = await this.isStagingMatch(updateInfo) - if (!isStagingMatch) { - return false - } - - // https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405033227 - // https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405030797 - const isLatestVersionNewer = isVersionGreaterThan(latestVersion, currentVersion) - const isLatestVersionOlder = isVersionLessThan(latestVersion, currentVersion) - - if (isLatestVersionNewer) { - return true - } - return this.allowDowngrade && isLatestVersionOlder - } - - protected async getUpdateInfoAndProvider(): Promise { - await this.app.whenReady() - - if (this.clientPromise == null) { - this.clientPromise = this.configOnDisk.value.then(it => createClient(it, this, this.createProviderRuntimeOptions())) - } - - const client = await this.clientPromise - const stagingUserId = await this.stagingUserIdPromise.value - client.setRequestHeaders(this.computeFinalHeaders({ "x-user-staging-id": stagingUserId })) - return { - info: await client.getLatestVersion(), - provider: client, - } - } - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - private createProviderRuntimeOptions() { - return { - isUseMultipleRangeRequest: true, - platform: this._testOnlyOptions == null ? (process.platform as ProviderPlatform) : this._testOnlyOptions.platform, - executor: this.httpExecutor, - } - } - - private async doCheckForUpdates(): Promise { - this.emit("checking-for-update") - - const result = await this.getUpdateInfoAndProvider() - const updateInfo = result.info - if (!(await this.isUpdateAvailable(updateInfo))) { - this._logger.info( - `Update for version ${this.currentVersion.format()} is not available (latest version: ${updateInfo.version}, downgrade is ${this.allowDowngrade ? "allowed" : "disallowed"}).` - ) - this.emit("update-not-available", updateInfo) - return { - versionInfo: updateInfo, - updateInfo, - } - } - - this.updateInfoAndProvider = result - this.onUpdateAvailable(updateInfo) - - const cancellationToken = new CancellationToken() - //noinspection ES6MissingAwait - return { - versionInfo: updateInfo, - updateInfo, - cancellationToken, - downloadPromise: this.autoDownload ? this.downloadUpdate(cancellationToken) : null, - } - } - - protected onUpdateAvailable(updateInfo: UpdateInfo): void { - this._logger.info( - `Found version ${updateInfo.version} (url: ${asArray(updateInfo.files) - .map(it => it.url) - .join(", ")})` - ) - this.emit("update-available", updateInfo) - } - - /** - * Start downloading update manually. You can use this method if `autoDownload` option is set to `false`. - * @returns {Promise>} Paths to downloaded files. - */ - downloadUpdate(cancellationToken: CancellationToken = new CancellationToken()): Promise> { - const updateInfoAndProvider = this.updateInfoAndProvider - if (updateInfoAndProvider == null) { - const error = new Error("Please check update first") - this.dispatchError(error) - return Promise.reject(error) - } - - this._logger.info( - `Downloading update from ${asArray(updateInfoAndProvider.info.files) - .map(it => it.url) - .join(", ")}` - ) - const errorHandler = (e: Error): Error => { - // https://github.com/electron-userland/electron-builder/issues/1150#issuecomment-436891159 - if (!(e instanceof CancellationError)) { - try { - this.dispatchError(e) - } catch (nestedError: any) { - this._logger.warn(`Cannot dispatch error event: ${nestedError.stack || nestedError}`) - } - } - - return e - } - - try { - return this.doDownloadUpdate({ - updateInfoAndProvider, - requestHeaders: this.computeRequestHeaders(updateInfoAndProvider.provider), - cancellationToken, - disableWebInstaller: this.disableWebInstaller, - disableDifferentialDownload: this.disableDifferentialDownload, - }).catch((e: any) => { - throw errorHandler(e) - }) - } catch (e: any) { - return Promise.reject(errorHandler(e)) - } - } - - protected dispatchError(e: Error): void { - this.emit("error", e, (e.stack || e).toString()) - } - - protected dispatchUpdateDownloaded(event: UpdateDownloadedEvent): void { - this.emit(UPDATE_DOWNLOADED, event) - } - - protected abstract doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> - - /** - * Restarts the app and installs the update after it has been downloaded. - * It should only be called after `update-downloaded` has been emitted. - * - * **Note:** `autoUpdater.quitAndInstall()` will close all application windows first and only emit `before-quit` event on `app` after that. - * This is different from the normal quit event sequence. - * - * @param isSilent *windows-only* Runs the installer in silent mode. Defaults to `false`. - * @param isForceRunAfter Run the app after finish even on silent install. Not applicable for macOS. - * Ignored if `isSilent` is set to `false`(In this case you can still set `autoRunAppAfterInstall` to `false` to prevent run the app after finish). - */ - abstract quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void - - private async loadUpdateConfig(): Promise { - if (this._appUpdateConfigPath == null) { - this._appUpdateConfigPath = this.app.appUpdateConfigPath - } - return load(await readFile(this._appUpdateConfigPath, "utf-8")) - } - - private computeRequestHeaders(provider: Provider): OutgoingHttpHeaders { - const fileExtraDownloadHeaders = provider.fileExtraDownloadHeaders - if (fileExtraDownloadHeaders != null) { - const requestHeaders = this.requestHeaders - return requestHeaders == null - ? fileExtraDownloadHeaders - : { - ...fileExtraDownloadHeaders, - ...requestHeaders, - } - } - return this.computeFinalHeaders({ accept: "*/*" }) - } - - private async getOrCreateStagingUserId(): Promise { - const file = path.join(this.app.userDataPath, ".updaterId") - try { - const id = await readFile(file, "utf-8") - if (UUID.check(id)) { - return id - } else { - this._logger.warn(`Staging user id file exists, but content was invalid: ${id}`) - } - } catch (e: any) { - if (e.code !== "ENOENT") { - this._logger.warn(`Couldn't read staging user ID, creating a blank one: ${e}`) - } - } - - const id = UUID.v5(randomBytes(4096), UUID.OID) - this._logger.info(`Generated new staging user ID: ${id}`) - try { - await outputFile(file, id) - } catch (e: any) { - this._logger.warn(`Couldn't write out staging user ID: ${e}`) - } - return id - } - - /** @internal */ - get isAddNoCacheQuery(): boolean { - const headers = this.requestHeaders - // https://github.com/electron-userland/electron-builder/issues/3021 - if (headers == null) { - return true - } - - for (const headerName of Object.keys(headers)) { - const s = headerName.toLowerCase() - if (s === "authorization" || s === "private-token") { - return false - } - } - return true - } - - /** - * @private - * @internal - */ - _testOnlyOptions: TestOnlyUpdaterOptions | null = null - - private async getOrCreateDownloadHelper(): Promise { - let result = this.downloadedUpdateHelper - if (result == null) { - const dirName = (await this.configOnDisk.value).updaterCacheDirName - const logger = this._logger - if (dirName == null) { - logger.error("updaterCacheDirName is not specified in app-update.yml Was app build using at least electron-builder 20.34.0?") - } - const cacheDir = path.join(this.app.baseCachePath, dirName || this.app.name) - if (logger.debug != null) { - logger.debug(`updater cache dir: ${cacheDir}`) - } - - result = new DownloadedUpdateHelper(cacheDir) - this.downloadedUpdateHelper = result - } - return result - } - - protected async executeDownload(taskOptions: DownloadExecutorTask): Promise> { - const fileInfo = taskOptions.fileInfo - const downloadOptions: DownloadOptions = { - headers: taskOptions.downloadUpdateOptions.requestHeaders, - cancellationToken: taskOptions.downloadUpdateOptions.cancellationToken, - sha2: (fileInfo.info as any).sha2, - sha512: fileInfo.info.sha512, - } - - if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { - downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) - } - - const updateInfo = taskOptions.downloadUpdateOptions.updateInfoAndProvider.info - const version = updateInfo.version - const packageInfo = fileInfo.packageInfo - - function getCacheUpdateFileName(): string { - // NodeJS URL doesn't decode automatically - const urlPath = decodeURIComponent(taskOptions.fileInfo.url.pathname) - if (urlPath.endsWith(`.${taskOptions.fileExtension}`)) { - return path.basename(urlPath) - } else { - // url like /latest, generate name - return taskOptions.fileInfo.info.url - } - } - - const downloadedUpdateHelper = await this.getOrCreateDownloadHelper() - const cacheDir = downloadedUpdateHelper.cacheDirForPendingUpdate - await mkdir(cacheDir, { recursive: true }) - const updateFileName = getCacheUpdateFileName() - let updateFile = path.join(cacheDir, updateFileName) - const packageFile = packageInfo == null ? null : path.join(cacheDir, `package-${version}${path.extname(packageInfo.path) || ".7z"}`) - - const done = async (isSaveCache: boolean) => { - await downloadedUpdateHelper.setDownloadedFile(updateFile, packageFile, updateInfo, fileInfo, updateFileName, isSaveCache) - await taskOptions.done!({ - ...updateInfo, - downloadedFile: updateFile, - }) - return packageFile == null ? [updateFile] : [updateFile, packageFile] - } - - const log = this._logger - const cachedUpdateFile = await downloadedUpdateHelper.validateDownloadedPath(updateFile, updateInfo, fileInfo, log) - if (cachedUpdateFile != null) { - updateFile = cachedUpdateFile - return await done(false) - } - - const removeFileIfAny = async () => { - await downloadedUpdateHelper.clear().catch(() => { - // ignore - }) - return await unlink(updateFile).catch(() => { - // ignore - }) - } - - const tempUpdateFile = await createTempUpdateFile(`temp-${updateFileName}`, cacheDir, log) - try { - await taskOptions.task(tempUpdateFile, downloadOptions, packageFile, removeFileIfAny) - await rename(tempUpdateFile, updateFile) - } catch (e: any) { - await removeFileIfAny() - - if (e instanceof CancellationError) { - log.info("cancelled") - this.emit("update-cancelled", updateInfo) - } - throw e - } - - log.info(`New version ${version} has been downloaded to ${updateFile}`) - return await done(true) - } - protected async differentialDownloadInstaller( - fileInfo: ResolvedUpdateFileInfo, - downloadUpdateOptions: DownloadUpdateOptions, - installerPath: string, - provider: Provider, - oldInstallerFileName: string - ): Promise { - try { - if (this._testOnlyOptions != null && !this._testOnlyOptions.isUseDifferentialDownload) { - return true - } - const blockmapFileUrls = blockmapFiles(fileInfo.url, this.app.version, downloadUpdateOptions.updateInfoAndProvider.info.version) - this._logger.info(`Download block maps (old: "${blockmapFileUrls[0]}", new: ${blockmapFileUrls[1]})`) - - const downloadBlockMap = async (url: URL): Promise => { - const data = await this.httpExecutor.downloadToBuffer(url, { - headers: downloadUpdateOptions.requestHeaders, - cancellationToken: downloadUpdateOptions.cancellationToken, - }) - - if (data == null || data.length === 0) { - throw new Error(`Blockmap "${url.href}" is empty`) - } - - try { - return JSON.parse(gunzipSync(data).toString()) - } catch (e: any) { - throw new Error(`Cannot parse blockmap "${url.href}", error: ${e}`) - } - } - - const downloadOptions: DifferentialDownloaderOptions = { - newUrl: fileInfo.url, - oldFile: path.join(this.downloadedUpdateHelper!.cacheDir, oldInstallerFileName), - logger: this._logger, - newFile: installerPath, - isUseMultipleRangeRequest: provider.isUseMultipleRangeRequest, - requestHeaders: downloadUpdateOptions.requestHeaders, - cancellationToken: downloadUpdateOptions.cancellationToken, - } - - if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { - downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) - } - - const blockMapDataList = await Promise.all(blockmapFileUrls.map(u => downloadBlockMap(u))) - await new GenericDifferentialDownloader(fileInfo.info, this.httpExecutor, downloadOptions).download(blockMapDataList[0], blockMapDataList[1]) - return false - } catch (e: any) { - this._logger.error(`Cannot download differentially, fallback to full download: ${e.stack || e}`) - if (this._testOnlyOptions != null) { - // test mode - throw e - } - return true - } - } -} - -export interface DownloadUpdateOptions { - readonly updateInfoAndProvider: UpdateInfoAndProvider - readonly requestHeaders: OutgoingHttpHeaders - readonly cancellationToken: CancellationToken - readonly disableWebInstaller?: boolean - readonly disableDifferentialDownload?: boolean -} - -function hasPrereleaseComponents(version: SemVer) { - const versionPrereleaseComponent = getVersionPreleaseComponents(version) - return versionPrereleaseComponent != null && versionPrereleaseComponent.length > 0 -} - -/** @private */ -export class NoOpLogger implements Logger { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - info(message?: any) { - // ignore - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - warn(message?: any) { - // ignore - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - error(message?: any) { - // ignore - } -} - -export interface UpdateInfoAndProvider { - info: UpdateInfo - provider: Provider -} - -export interface DownloadExecutorTask { - readonly fileExtension: string - readonly fileInfo: ResolvedUpdateFileInfo - readonly downloadUpdateOptions: DownloadUpdateOptions - readonly task: (destinationFile: string, downloadOptions: DownloadOptions, packageFile: string | null, removeTempDirIfAny: () => Promise) => Promise - - readonly done?: (event: UpdateDownloadedEvent) => Promise -} - -export interface DownloadNotification { - body: string - title: string -} - -/** @private */ -export interface TestOnlyUpdaterOptions { - platform: ProviderPlatform - - isUseDifferentialDownload?: boolean -} diff --git a/src/main/electron-updater/BaseUpdater.ts b/src/main/electron-updater/BaseUpdater.ts deleted file mode 100644 index 4851435..0000000 --- a/src/main/electron-updater/BaseUpdater.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { AllPublishOptions } from "builder-util-runtime" -import { spawn, SpawnOptions, spawnSync, StdioOptions } from "child_process" -import { AppAdapter } from "./AppAdapter" -import { AppUpdater, DownloadExecutorTask } from "./AppUpdater" - -export abstract class BaseUpdater extends AppUpdater { - protected quitAndInstallCalled = false - private quitHandlerAdded = false - - protected constructor(options?: AllPublishOptions | null, app?: AppAdapter) { - super(options, app) - } - - quitAndInstall(isSilent = false, isForceRunAfter = false): void { - this._logger.info(`Install on explicit quitAndInstall`) - // If NOT in silent mode use `autoRunAppAfterInstall` to determine whether to force run the app - const isInstalled = this.install(isSilent, isSilent ? isForceRunAfter : this.autoRunAppAfterInstall) - if (isInstalled) { - setImmediate(() => { - // this event is normally emitted when calling quitAndInstall, this emulates that - require("electron").autoUpdater.emit("before-quit-for-update") - this.app.quit() - }) - } else { - this.quitAndInstallCalled = false - } - } - - protected executeDownload(taskOptions: DownloadExecutorTask): Promise> { - return super.executeDownload({ - ...taskOptions, - done: event => { - this.dispatchUpdateDownloaded(event) - this.addQuitHandler() - return Promise.resolve() - }, - }) - } - - // must be sync - protected abstract doInstall(options: InstallOptions): boolean - - // must be sync (because quit even handler is not async) - install(isSilent = false, isForceRunAfter = false): boolean { - if (this.quitAndInstallCalled) { - this._logger.warn("install call ignored: quitAndInstallCalled is set to true") - return false - } - - const downloadedUpdateHelper = this.downloadedUpdateHelper - const installerPath = downloadedUpdateHelper == null ? null : downloadedUpdateHelper.file - const downloadedFileInfo = downloadedUpdateHelper == null ? null : downloadedUpdateHelper.downloadedFileInfo - if (installerPath == null || downloadedFileInfo == null) { - this.dispatchError(new Error("No valid update available, can't quit and install")) - return false - } - - // prevent calling several times - this.quitAndInstallCalled = true - - try { - this._logger.info(`Install: isSilent: ${isSilent}, isForceRunAfter: ${isForceRunAfter}`) - return this.doInstall({ - installerPath, - isSilent, - isForceRunAfter, - isAdminRightsRequired: downloadedFileInfo.isAdminRightsRequired, - }) - } catch (e: any) { - this.dispatchError(e) - return false - } - } - - protected addQuitHandler(): void { - if (this.quitHandlerAdded || !this.autoInstallOnAppQuit) { - return - } - - this.quitHandlerAdded = true - - this.app.onQuit(exitCode => { - if (this.quitAndInstallCalled) { - this._logger.info("Update installer has already been triggered. Quitting application.") - return - } - - if (!this.autoInstallOnAppQuit) { - this._logger.info("Update will not be installed on quit because autoInstallOnAppQuit is set to false.") - return - } - - if (exitCode !== 0) { - this._logger.info(`Update will be not installed on quit because application is quitting with exit code ${exitCode}`) - return - } - - this._logger.info("Auto install update on quit") - this.install(true, false) - }) - } - - protected wrapSudo() { - const { name } = this.app - const installComment = `"${name} would like to update"` - const sudo = this.spawnSyncLog("which gksudo || which kdesudo || which pkexec || which beesu") - const command = [sudo] - if (/kdesudo/i.test(sudo)) { - command.push("--comment", installComment) - command.push("-c") - } else if (/gksudo/i.test(sudo)) { - command.push("--message", installComment) - } else if (/pkexec/i.test(sudo)) { - command.push("--disable-internal-agent") - } - return command.join(" ") - } - - protected spawnSyncLog(cmd: string, args: string[] = [], env = {}): string { - this._logger.info(`Executing: ${cmd} with args: ${args}`) - const response = spawnSync(cmd, args, { - env: { ...process.env, ...env }, - encoding: "utf-8", - shell: true, - }) - return response.stdout.trim() - } - - /** - * This handles both node 8 and node 10 way of emitting error when spawning a process - * - node 8: Throws the error - * - node 10: Emit the error(Need to listen with on) - */ - // https://github.com/electron-userland/electron-builder/issues/1129 - // Node 8 sends errors: https://nodejs.org/dist/latest-v8.x/docs/api/errors.html#errors_common_system_errors - protected async spawnLog(cmd: string, args: string[] = [], env: any = undefined, stdio: StdioOptions = "ignore"): Promise { - this._logger.info(`Executing: ${cmd} with args: ${args}`) - return new Promise((resolve, reject) => { - try { - const params: SpawnOptions = { stdio, env, detached: true } - const p = spawn(cmd, args, params) - p.on("error", error => { - reject(error) - }) - p.unref() - if (p.pid !== undefined) { - resolve(true) - } - } catch (error) { - reject(error) - } - }) - } -} - -export interface InstallOptions { - readonly installerPath: string - readonly isSilent: boolean - readonly isForceRunAfter: boolean - readonly isAdminRightsRequired: boolean -} diff --git a/src/main/electron-updater/DebUpdater.ts b/src/main/electron-updater/DebUpdater.ts deleted file mode 100644 index 19705a7..0000000 --- a/src/main/electron-updater/DebUpdater.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { AllPublishOptions } from "builder-util-runtime" -import { AppAdapter } from "./AppAdapter" -import { DownloadUpdateOptions } from "./AppUpdater" -import { BaseUpdater, InstallOptions } from "./BaseUpdater" -import { DOWNLOAD_PROGRESS } from "./main" -import { findFile } from "./providers/Provider" - -export class DebUpdater extends BaseUpdater { - constructor(options?: AllPublishOptions | null, app?: AppAdapter) { - super(options, app) - } - - /*** @private */ - protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { - const provider = downloadUpdateOptions.updateInfoAndProvider.provider - const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "deb", ["AppImage", "rpm"])! - return this.executeDownload({ - fileExtension: "deb", - fileInfo, - downloadUpdateOptions, - task: async (updateFile, downloadOptions) => { - if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { - downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) - } - await this.httpExecutor.download(fileInfo.url, updateFile, downloadOptions) - }, - }) - } - - protected doInstall(options: InstallOptions): boolean { - const sudo = this.wrapSudo() - // pkexec doesn't want the command to be wrapped in " quotes - const wrapper = /pkexec/i.test(sudo) ? "" : `"` - const cmd = ["dpkg", "-i", options.installerPath, "||", "apt-get", "install", "-f", "-y"] - this.spawnSyncLog(sudo, [`${wrapper}/bin/bash`, "-c", `'${cmd.join(" ")}'${wrapper}`]) - if (options.isForceRunAfter) { - this.app.relaunch() - } - return true - } -} diff --git a/src/main/electron-updater/DownloadedUpdateHelper.ts b/src/main/electron-updater/DownloadedUpdateHelper.ts deleted file mode 100644 index 12a312c..0000000 --- a/src/main/electron-updater/DownloadedUpdateHelper.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { UpdateInfo } from "builder-util-runtime" -import { createHash } from "crypto" -import { createReadStream } from "fs" -// @ts-ignore -import * as isEqual from "lodash.isequal" -import { Logger, ResolvedUpdateFileInfo } from "./main" -import { pathExists, readJson, emptyDir, outputJson, unlink } from "fs-extra" -import * as path from "path" - -/** @private **/ -export class DownloadedUpdateHelper { - private _file: string | null = null - private _packageFile: string | null = null - - private versionInfo: UpdateInfo | null = null - private fileInfo: ResolvedUpdateFileInfo | null = null - - constructor(readonly cacheDir: string) {} - - private _downloadedFileInfo: CachedUpdateInfo | null = null - get downloadedFileInfo(): CachedUpdateInfo | null { - return this._downloadedFileInfo - } - - get file(): string | null { - return this._file - } - - get packageFile(): string | null { - return this._packageFile - } - - get cacheDirForPendingUpdate(): string { - return path.join(this.cacheDir, "pending") - } - - async validateDownloadedPath(updateFile: string, updateInfo: UpdateInfo, fileInfo: ResolvedUpdateFileInfo, logger: Logger): Promise { - if (this.versionInfo != null && this.file === updateFile && this.fileInfo != null) { - // update has already been downloaded from this running instance - // check here only existence, not checksum - if (isEqual(this.versionInfo, updateInfo) && isEqual(this.fileInfo.info, fileInfo.info) && (await pathExists(updateFile))) { - return updateFile - } else { - return null - } - } - - // update has already been downloaded from some previous app launch - const cachedUpdateFile = await this.getValidCachedUpdateFile(fileInfo, logger) - if (cachedUpdateFile === null) { - return null - } - logger.info(`Update has already been downloaded to ${updateFile}).`) - this._file = cachedUpdateFile - return cachedUpdateFile - } - - async setDownloadedFile( - downloadedFile: string, - packageFile: string | null, - versionInfo: UpdateInfo, - fileInfo: ResolvedUpdateFileInfo, - updateFileName: string, - isSaveCache: boolean - ): Promise { - this._file = downloadedFile - this._packageFile = packageFile - this.versionInfo = versionInfo - this.fileInfo = fileInfo - this._downloadedFileInfo = { - fileName: updateFileName, - sha512: fileInfo.info.sha512, - isAdminRightsRequired: fileInfo.info.isAdminRightsRequired === true, - } - - if (isSaveCache) { - await outputJson(this.getUpdateInfoFile(), this._downloadedFileInfo) - } - } - - async clear(): Promise { - this._file = null - this._packageFile = null - this.versionInfo = null - this.fileInfo = null - await this.cleanCacheDirForPendingUpdate() - } - - private async cleanCacheDirForPendingUpdate(): Promise { - try { - // remove stale data - await emptyDir(this.cacheDirForPendingUpdate) - } catch (ignore) { - // ignore - } - } - - /** - * Returns "update-info.json" which is created in the update cache directory's "pending" subfolder after the first update is downloaded. If the update file does not exist then the cache is cleared and recreated. If the update file exists then its properties are validated. - * @param fileInfo - * @param logger - */ - private async getValidCachedUpdateFile(fileInfo: ResolvedUpdateFileInfo, logger: Logger): Promise { - const updateInfoFilePath: string = this.getUpdateInfoFile() - - const doesUpdateInfoFileExist = await pathExists(updateInfoFilePath) - if (!doesUpdateInfoFileExist) { - return null - } - - let cachedInfo: CachedUpdateInfo - try { - cachedInfo = await readJson(updateInfoFilePath) - } catch (error: any) { - let message = `No cached update info available` - if (error.code !== "ENOENT") { - await this.cleanCacheDirForPendingUpdate() - message += ` (error on read: ${error.message})` - } - logger.info(message) - return null - } - - const isCachedInfoFileNameValid = cachedInfo?.fileName !== null ?? false - if (!isCachedInfoFileNameValid) { - logger.warn(`Cached update info is corrupted: no fileName, directory for cached update will be cleaned`) - await this.cleanCacheDirForPendingUpdate() - return null - } - - if (fileInfo.info.sha512 !== cachedInfo.sha512) { - logger.info( - `Cached update sha512 checksum doesn't match the latest available update. New update must be downloaded. Cached: ${cachedInfo.sha512}, expected: ${fileInfo.info.sha512}. Directory for cached update will be cleaned` - ) - await this.cleanCacheDirForPendingUpdate() - return null - } - - const updateFile = path.join(this.cacheDirForPendingUpdate, cachedInfo.fileName) - if (!(await pathExists(updateFile))) { - logger.info("Cached update file doesn't exist") - return null - } - - const sha512 = await hashFile(updateFile) - if (fileInfo.info.sha512 !== sha512) { - logger.warn(`Sha512 checksum doesn't match the latest available update. New update must be downloaded. Cached: ${sha512}, expected: ${fileInfo.info.sha512}`) - await this.cleanCacheDirForPendingUpdate() - return null - } - this._downloadedFileInfo = cachedInfo - return updateFile - } - - private getUpdateInfoFile(): string { - return path.join(this.cacheDirForPendingUpdate, "update-info.json") - } -} - -interface CachedUpdateInfo { - fileName: string - sha512: string - readonly isAdminRightsRequired: boolean -} - -function hashFile(file: string, algorithm = "sha512", encoding: "base64" | "hex" = "base64", options?: any): Promise { - return new Promise((resolve, reject) => { - const hash = createHash(algorithm) - hash.on("error", reject).setEncoding(encoding) - - createReadStream(file, { ...options, highWaterMark: 1024 * 1024 /* better to use more memory but hash faster */ }) - .on("error", reject) - .on("end", () => { - hash.end() - resolve(hash.read() as string) - }) - .pipe(hash, { end: false }) - }) -} - -export async function createTempUpdateFile(name: string, cacheDir: string, log: Logger): Promise { - // https://github.com/electron-userland/electron-builder/pull/2474#issuecomment-366481912 - let nameCounter = 0 - let result = path.join(cacheDir, name) - for (let i = 0; i < 3; i++) { - try { - await unlink(result) - return result - } catch (e: any) { - if (e.code === "ENOENT") { - return result - } - - log.warn(`Error on remove temp update file: ${e}`) - result = path.join(cacheDir, `${nameCounter++}-${name}`) - } - } - return result -} diff --git a/src/main/electron-updater/ElectronAppAdapter.ts b/src/main/electron-updater/ElectronAppAdapter.ts deleted file mode 100644 index 0999dad..0000000 --- a/src/main/electron-updater/ElectronAppAdapter.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as path from "path" -import { AppAdapter, getAppCacheDir } from "./AppAdapter" - -export class ElectronAppAdapter implements AppAdapter { - constructor(private readonly app = require("electron").app) {} - - whenReady(): Promise { - return this.app.whenReady() - } - - get version(): string { - return this.app.getVersion() - } - - get name(): string { - return this.app.getName() - } - - get isPackaged(): boolean { - return this.app.isPackaged === true - } - - get appUpdateConfigPath(): string { - return this.isPackaged ? path.join(process.resourcesPath!, "app-update.yml") : path.join(this.app.getAppPath(), "dev-app-update.yml") - } - - get userDataPath(): string { - return this.app.getPath("userData") - } - - get baseCachePath(): string { - return getAppCacheDir() - } - - quit(): void { - this.app.quit() - } - - relaunch(): void { - this.app.relaunch() - } - - onQuit(handler: (exitCode: number) => void): void { - this.app.once("quit", (_: Event, exitCode: number) => handler(exitCode)) - } -} diff --git a/src/main/electron-updater/MacUpdater.ts b/src/main/electron-updater/MacUpdater.ts deleted file mode 100644 index a989acb..0000000 --- a/src/main/electron-updater/MacUpdater.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { AllPublishOptions, newError, safeStringifyJson } from "builder-util-runtime" -import { pathExistsSync, stat } from "fs-extra" -import { createReadStream, copyFileSync } from "fs" -import * as path from "path" -import { createServer, IncomingMessage, Server, ServerResponse } from "http" -import { AppAdapter } from "./AppAdapter" -import { AppUpdater, DownloadUpdateOptions } from "./AppUpdater" -import { ResolvedUpdateFileInfo, UpdateDownloadedEvent } from "./main" -import { findFile } from "./providers/Provider" -import AutoUpdater = Electron.AutoUpdater -import { execFileSync } from "child_process" -import { randomBytes } from "crypto" - -export class MacUpdater extends AppUpdater { - private readonly nativeUpdater: AutoUpdater = require("electron").autoUpdater - - private squirrelDownloadedUpdate = false - - private server?: Server - - constructor(options?: AllPublishOptions, app?: AppAdapter) { - super(options, app) - - this.nativeUpdater.on("error", it => { - this._logger.warn(it) - this.emit("error", it) - }) - this.nativeUpdater.on("update-downloaded", () => { - this.squirrelDownloadedUpdate = true - this.debug("nativeUpdater.update-downloaded") - }) - } - - private debug(message: string): void { - if (this._logger.debug != null) { - this._logger.debug(message) - } - } - - private closeServerIfExists() { - if (this.server) { - this.debug("Closing proxy server") - this.server.close(err => { - if (err) { - this.debug("proxy server wasn't already open, probably attempted closing again as a safety check before quit") - } - }) - } - } - - protected async doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { - let files = downloadUpdateOptions.updateInfoAndProvider.provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info) - - const log = this._logger - - // detect if we are running inside Rosetta emulation - const sysctlRosettaInfoKey = "sysctl.proc_translated" - let isRosetta = false - try { - this.debug("Checking for macOS Rosetta environment") - const result = execFileSync("sysctl", [sysctlRosettaInfoKey], { encoding: "utf8" }) - isRosetta = result.includes(`${sysctlRosettaInfoKey}: 1`) - log.info(`Checked for macOS Rosetta environment (isRosetta=${isRosetta})`) - } catch (e: any) { - log.warn(`sysctl shell command to check for macOS Rosetta environment failed: ${e}`) - } - - let isArm64Mac = false - try { - this.debug("Checking for arm64 in uname") - const result = execFileSync("uname", ["-a"], { encoding: "utf8" }) - const isArm = result.includes("ARM") - log.info(`Checked 'uname -a': arm64=${isArm}`) - isArm64Mac = isArm64Mac || isArm - } catch (e: any) { - log.warn(`uname shell command to check for arm64 failed: ${e}`) - } - - isArm64Mac = isArm64Mac || process.arch === "arm64" || isRosetta - - // allow arm64 macs to install universal or rosetta2(x64) - https://github.com/electron-userland/electron-builder/pull/5524 - const isArm64 = (file: ResolvedUpdateFileInfo) => file.url.pathname.includes("arm64") || file.info.url?.includes("arm64") - if (isArm64Mac && files.some(isArm64)) { - files = files.filter(file => isArm64Mac === isArm64(file)) - } else { - files = files.filter(file => !isArm64(file)) - } - - const zipFileInfo = findFile(files, "zip", ["pkg", "dmg"]) - - if (zipFileInfo == null) { - throw newError(`ZIP file not provided: ${safeStringifyJson(files)}`, "ERR_UPDATER_ZIP_FILE_NOT_FOUND") - } - - const provider = downloadUpdateOptions.updateInfoAndProvider.provider - const CURRENT_MAC_APP_ZIP_FILE_NAME = "update.zip" - let cachedUpdateFile: string = "" - - return this.executeDownload({ - fileExtension: "zip", - fileInfo: zipFileInfo, - downloadUpdateOptions, - task: async (destinationFile, downloadOptions) => { - cachedUpdateFile = path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_MAC_APP_ZIP_FILE_NAME) - const canDifferentialDownload = () => { - if (!pathExistsSync(cachedUpdateFile)) { - log.info("Unable to locate previous update.zip for differential download (is this first install?), falling back to full download") - return false - } - return !downloadUpdateOptions.disableDifferentialDownload - } - let differentialDownloadFailed = true - if (canDifferentialDownload()) { - differentialDownloadFailed = await this.differentialDownloadInstaller(zipFileInfo, downloadUpdateOptions, destinationFile, provider, CURRENT_MAC_APP_ZIP_FILE_NAME) - } - - if (differentialDownloadFailed) { - await this.httpExecutor.download(zipFileInfo.url, destinationFile, downloadOptions) - } - }, - done: event => { - try { - copyFileSync(event.downloadedFile, cachedUpdateFile) - } catch (error: any) { - this._logger.error(`Unable to copy file for caching: ${error.message}`) - } - return this.updateDownloaded(zipFileInfo, event) - }, - }) - } - - private async updateDownloaded(zipFileInfo: ResolvedUpdateFileInfo, event: UpdateDownloadedEvent): Promise> { - const downloadedFile = event.downloadedFile - const updateFileSize = zipFileInfo.info.size ?? (await stat(downloadedFile)).size - - const log = this._logger - const logContext = `fileToProxy=${zipFileInfo.url.href}` - this.closeServerIfExists() - this.debug(`Creating proxy server for native Squirrel.Mac (${logContext})`) - this.server = createServer() - this.debug(`Proxy server for native Squirrel.Mac is created (${logContext})`) - this.server.on("close", () => { - log.info(`Proxy server for native Squirrel.Mac is closed (${logContext})`) - }) - - // must be called after server is listening, otherwise address is null - const getServerUrl = (s: Server): string => { - const address = s.address() - if (typeof address === "string") { - return address - } - return `http://127.0.0.1:${address?.port}` - } - - return await new Promise>((resolve, reject) => { - const pass = randomBytes(64).toString("base64").replace(/\//g, "_").replace(/\+/g, "-") - const authInfo = Buffer.from(`autoupdater:${pass}`, "ascii") - - // insecure random is ok - const fileUrl = `/${randomBytes(64).toString("hex")}.zip` - this.server!.on("request", (request: IncomingMessage, response: ServerResponse) => { - const requestUrl = request.url! - log.info(`${requestUrl} requested`) - if (requestUrl === "/") { - // check for basic auth header - if (!request.headers.authorization || request.headers.authorization.indexOf("Basic ") === -1) { - response.statusCode = 401 - response.statusMessage = "Invalid Authentication Credentials" - response.end() - log.warn("No authenthication info") - return - } - - // verify auth credentials - const base64Credentials = request.headers.authorization.split(" ")[1] - const credentials = Buffer.from(base64Credentials, "base64").toString("ascii") - const [username, password] = credentials.split(":") - if (username !== "autoupdater" || password !== pass) { - response.statusCode = 401 - response.statusMessage = "Invalid Authentication Credentials" - response.end() - log.warn("Invalid authenthication credentials") - return - } - - const data = Buffer.from(`{ "url": "${getServerUrl(this.server!)}${fileUrl}" }`) - response.writeHead(200, { "Content-Type": "application/json", "Content-Length": data.length }) - response.end(data) - return - } - - if (!requestUrl.startsWith(fileUrl)) { - log.warn(`${requestUrl} requested, but not supported`) - response.writeHead(404) - response.end() - return - } - - log.info(`${fileUrl} requested by Squirrel.Mac, pipe ${downloadedFile}`) - - let errorOccurred = false - response.on("finish", () => { - if (!errorOccurred) { - this.nativeUpdater.removeListener("error", reject) - resolve([]) - } - }) - - const readStream = createReadStream(downloadedFile) - readStream.on("error", error => { - try { - response.end() - } catch (e: any) { - log.warn(`cannot end response: ${e}`) - } - errorOccurred = true - this.nativeUpdater.removeListener("error", reject) - reject(new Error(`Cannot pipe "${downloadedFile}": ${error}`)) - }) - - response.writeHead(200, { - "Content-Type": "application/zip", - "Content-Length": updateFileSize, - }) - readStream.pipe(response) - }) - - this.debug(`Proxy server for native Squirrel.Mac is starting to listen (${logContext})`) - - this.server!.listen(0, "127.0.0.1", () => { - this.debug(`Proxy server for native Squirrel.Mac is listening (address=${getServerUrl(this.server!)}, ${logContext})`) - this.nativeUpdater.setFeedURL({ - url: getServerUrl(this.server!), - headers: { - "Cache-Control": "no-cache", - Authorization: `Basic ${authInfo.toString("base64")}`, - }, - }) - - // The update has been downloaded and is ready to be served to Squirrel - this.dispatchUpdateDownloaded(event) - - if (this.autoInstallOnAppQuit) { - this.nativeUpdater.once("error", reject) - // This will trigger fetching and installing the file on Squirrel side - this.nativeUpdater.checkForUpdates() - } else { - resolve([]) - } - }) - }) - } - - quitAndInstall(): void { - if (this.squirrelDownloadedUpdate) { - // update already fetched by Squirrel, it's ready to install - this.nativeUpdater.quitAndInstall() - this.closeServerIfExists() - } else { - // Quit and install as soon as Squirrel get the update - this.nativeUpdater.on("update-downloaded", () => { - this.nativeUpdater.quitAndInstall() - this.closeServerIfExists() - }) - - if (!this.autoInstallOnAppQuit) { - /** - * If this was not `true` previously then MacUpdater.doDownloadUpdate() - * would not actually initiate the downloading by electron's autoUpdater - */ - this.nativeUpdater.checkForUpdates() - } - } - } -} diff --git a/src/main/electron-updater/NsisUpdater.ts b/src/main/electron-updater/NsisUpdater.ts deleted file mode 100644 index de4780c..0000000 --- a/src/main/electron-updater/NsisUpdater.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { AllPublishOptions, newError, PackageFileInfo, CURRENT_APP_INSTALLER_FILE_NAME, CURRENT_APP_PACKAGE_FILE_NAME } from "builder-util-runtime" -import * as path from "path" -import { AppAdapter } from "./AppAdapter" -import { DownloadUpdateOptions } from "./AppUpdater" -import { BaseUpdater, InstallOptions } from "./BaseUpdater" -import { DifferentialDownloaderOptions } from "./differentialDownloader/DifferentialDownloader" -import { FileWithEmbeddedBlockMapDifferentialDownloader } from "./differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader" -import { DOWNLOAD_PROGRESS, verifyUpdateCodeSignature } from "./main" -import { findFile, Provider } from "./providers/Provider" -import { unlink } from "fs-extra" -import { verifySignature } from "./windowsExecutableCodeSignatureVerifier" -import { URL } from "url" - -export class NsisUpdater extends BaseUpdater { - /** - * Specify custom install directory path - * - */ - installDirectory?: string - - constructor(options?: AllPublishOptions | null, app?: AppAdapter) { - super(options, app) - } - - protected _verifyUpdateCodeSignature: verifyUpdateCodeSignature = (publisherNames: Array, unescapedTempUpdateFile: string) => - verifySignature(publisherNames, unescapedTempUpdateFile, this._logger) - - /** - * The verifyUpdateCodeSignature. You can pass [win-verify-signature](https://github.com/beyondkmp/win-verify-trust) or another custom verify function: ` (publisherName: string[], path: string) => Promise`. - * The default verify function uses [windowsExecutableCodeSignatureVerifier](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/windowsExecutableCodeSignatureVerifier.ts) - */ - get verifyUpdateCodeSignature(): verifyUpdateCodeSignature { - return this._verifyUpdateCodeSignature - } - - set verifyUpdateCodeSignature(value: verifyUpdateCodeSignature) { - if (value) { - this._verifyUpdateCodeSignature = value - } - } - - /*** @private */ - protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { - const provider = downloadUpdateOptions.updateInfoAndProvider.provider - const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "exe")! - return this.executeDownload({ - fileExtension: "exe", - downloadUpdateOptions, - fileInfo, - task: async (destinationFile, downloadOptions, packageFile, removeTempDirIfAny) => { - const packageInfo = fileInfo.packageInfo - const isWebInstaller = packageInfo != null && packageFile != null - if (isWebInstaller && downloadUpdateOptions.disableWebInstaller) { - throw newError( - `Unable to download new version ${downloadUpdateOptions.updateInfoAndProvider.info.version}. Web Installers are disabled`, - "ERR_UPDATER_WEB_INSTALLER_DISABLED" - ) - } - if (!isWebInstaller && !downloadUpdateOptions.disableWebInstaller) { - this._logger.warn( - "disableWebInstaller is set to false, you should set it to true if you do not plan on using a web installer. This will default to true in a future version." - ) - } - if ( - isWebInstaller || - downloadUpdateOptions.disableDifferentialDownload || - (await this.differentialDownloadInstaller(fileInfo, downloadUpdateOptions, destinationFile, provider, CURRENT_APP_INSTALLER_FILE_NAME)) - ) { - await this.httpExecutor.download(fileInfo.url, destinationFile, downloadOptions) - } - - const signatureVerificationStatus = await this.verifySignature(destinationFile) - if (signatureVerificationStatus != null) { - await removeTempDirIfAny() - // noinspection ThrowInsideFinallyBlockJS - throw newError( - `New version ${downloadUpdateOptions.updateInfoAndProvider.info.version} is not signed by the application owner: ${signatureVerificationStatus}`, - "ERR_UPDATER_INVALID_SIGNATURE" - ) - } - - if (isWebInstaller) { - if (await this.differentialDownloadWebPackage(downloadUpdateOptions, packageInfo, packageFile, provider)) { - try { - await this.httpExecutor.download(new URL(packageInfo.path), packageFile, { - headers: downloadUpdateOptions.requestHeaders, - cancellationToken: downloadUpdateOptions.cancellationToken, - sha512: packageInfo.sha512, - }) - } catch (e: any) { - try { - await unlink(packageFile) - } catch (ignored) { - // ignore - } - - throw e - } - } - } - }, - }) - } - - // $certificateInfo = (Get-AuthenticodeSignature 'xxx\yyy.exe' - // | where {$_.Status.Equals([System.Management.Automation.SignatureStatus]::Valid) -and $_.SignerCertificate.Subject.Contains("CN=siemens.com")}) - // | Out-String ; if ($certificateInfo) { exit 0 } else { exit 1 } - private async verifySignature(tempUpdateFile: string): Promise { - let publisherName: Array | string | null - try { - publisherName = (await this.configOnDisk.value).publisherName - if (publisherName == null) { - return null - } - } catch (e: any) { - if (e.code === "ENOENT") { - // no app-update.yml - return null - } - throw e - } - return await this._verifyUpdateCodeSignature(Array.isArray(publisherName) ? publisherName : [publisherName], tempUpdateFile) - } - - protected doInstall(options: InstallOptions): boolean { - const args = ["--updated"] - if (options.isSilent) { - args.push("/S") - } - - if (options.isForceRunAfter) { - args.push("--force-run") - } - - if (this.installDirectory) { - // maybe check if folder exists - args.push(`/D=${this.installDirectory}`) - } - - const packagePath = this.downloadedUpdateHelper == null ? null : this.downloadedUpdateHelper.packageFile - if (packagePath != null) { - // only = form is supported - args.push(`--package-file=${packagePath}`) - } - - const callUsingElevation = (): void => { - this.spawnLog(path.join(process.resourcesPath!, "elevate.exe"), [options.installerPath].concat(args)).catch(e => this.dispatchError(e)) - } - - if (options.isAdminRightsRequired) { - this._logger.info("isAdminRightsRequired is set to true, run installer using elevate.exe") - callUsingElevation() - return true - } - - this.spawnLog(options.installerPath, args).catch((e: Error) => { - // https://github.com/electron-userland/electron-builder/issues/1129 - // Node 8 sends errors: https://nodejs.org/dist/latest-v8.x/docs/api/errors.html#errors_common_system_errors - const errorCode = (e as NodeJS.ErrnoException).code - this._logger.info( - `Cannot run installer: error code: ${errorCode}, error message: "${e.message}", will be executed again using elevate if EACCES, and will try to use electron.shell.openItem if ENOENT` - ) - if (errorCode === "UNKNOWN" || errorCode === "EACCES") { - callUsingElevation() - } else if (errorCode === "ENOENT") { - require("electron") - .shell.openPath(options.installerPath) - .catch((err: Error) => this.dispatchError(err)) - } else { - this.dispatchError(e) - } - }) - return true - } - - private async differentialDownloadWebPackage( - downloadUpdateOptions: DownloadUpdateOptions, - packageInfo: PackageFileInfo, - packagePath: string, - provider: Provider - ): Promise { - if (packageInfo.blockMapSize == null) { - return true - } - - try { - const downloadOptions: DifferentialDownloaderOptions = { - newUrl: new URL(packageInfo.path), - oldFile: path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_APP_PACKAGE_FILE_NAME), - logger: this._logger, - newFile: packagePath, - requestHeaders: this.requestHeaders, - isUseMultipleRangeRequest: provider.isUseMultipleRangeRequest, - cancellationToken: downloadUpdateOptions.cancellationToken, - } - - if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { - downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) - } - - await new FileWithEmbeddedBlockMapDifferentialDownloader(packageInfo, this.httpExecutor, downloadOptions).download() - } catch (e: any) { - this._logger.error(`Cannot download differentially, fallback to full download: ${e.stack || e}`) - // during test (developer machine mac or linux) we must throw error - return process.platform === "win32" - } - return false - } -} diff --git a/src/main/electron-updater/RpmUpdater.ts b/src/main/electron-updater/RpmUpdater.ts deleted file mode 100644 index 3b7f9f9..0000000 --- a/src/main/electron-updater/RpmUpdater.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { AllPublishOptions } from "builder-util-runtime" -import { AppAdapter } from "./AppAdapter" -import { DownloadUpdateOptions } from "./AppUpdater" -import { BaseUpdater, InstallOptions } from "./BaseUpdater" -import { DOWNLOAD_PROGRESS } from "./main" -import { findFile } from "./providers/Provider" - -export class RpmUpdater extends BaseUpdater { - constructor(options?: AllPublishOptions | null, app?: AppAdapter) { - super(options, app) - } - - /*** @private */ - protected doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise> { - const provider = downloadUpdateOptions.updateInfoAndProvider.provider - const fileInfo = findFile(provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info), "rpm", ["AppImage", "deb"])! - return this.executeDownload({ - fileExtension: "rpm", - fileInfo, - downloadUpdateOptions, - task: async (updateFile, downloadOptions) => { - if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { - downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) - } - await this.httpExecutor.download(fileInfo.url, updateFile, downloadOptions) - }, - }) - } - - protected doInstall(options: InstallOptions): boolean { - const upgradePath = options.installerPath - const sudo = this.wrapSudo() - // pkexec doesn't want the command to be wrapped in " quotes - const wrapper = /pkexec/i.test(sudo) ? "" : `"` - const packageManager = this.spawnSyncLog("which zypper") - let cmd: string[] - if (!packageManager) { - const packageManager = this.spawnSyncLog("which dnf || which yum") - cmd = [packageManager, "-y", "remove", `'${this.app.name}'`, ";", packageManager, "-y", "install", upgradePath] - } else { - cmd = [ - packageManager, - "remove", - "-y", - `'${this.app.name}'`, - ";", - packageManager, - "clean", - "--all", - ";", - packageManager, - "--no-refresh", - "install", - "--allow-unsigned-rpm", - "-y", - "-f", - upgradePath, - ] - } - this.spawnSyncLog(sudo, [`${wrapper}/bin/bash`, "-c", `'${cmd.join(" ")}'${wrapper}`]) - if (options.isForceRunAfter) { - this.app.relaunch() - } - return true - } -} diff --git a/src/main/electron-updater/differentialDownloader/DataSplitter.ts b/src/main/electron-updater/differentialDownloader/DataSplitter.ts deleted file mode 100644 index 0862529..0000000 --- a/src/main/electron-updater/differentialDownloader/DataSplitter.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { newError } from "builder-util-runtime" -import { createReadStream } from "fs" -import { Writable } from "stream" -import { Operation, OperationKind } from "./downloadPlanBuilder" - -const DOUBLE_CRLF = Buffer.from("\r\n\r\n") - -enum ReadState { - INIT, - HEADER, - BODY, -} - -export interface PartListDataTask { - readonly oldFileFd: number - readonly tasks: Array - readonly start: number - readonly end: number -} - -export function copyData(task: Operation, out: Writable, oldFileFd: number, reject: (error: Error) => void, resolve: () => void): void { - const readStream = createReadStream("", { - fd: oldFileFd, - autoClose: false, - start: task.start, - // end is inclusive - end: task.end - 1, - }) - readStream.on("error", reject) - readStream.once("end", resolve) - readStream.pipe(out, { - end: false, - }) -} - -export class DataSplitter extends Writable { - partIndex = -1 - - private headerListBuffer: Buffer | null = null - private readState = ReadState.INIT - private ignoreByteCount = 0 - private remainingPartDataCount = 0 - - private readonly boundaryLength: number - - constructor( - private readonly out: Writable, - private readonly options: PartListDataTask, - private readonly partIndexToTaskIndex: Map, - boundary: string, - private readonly partIndexToLength: Array, - private readonly finishHandler: () => any - ) { - super() - - this.boundaryLength = boundary.length + 4 /* size of \r\n-- */ - // first chunk doesn't start with \r\n - this.ignoreByteCount = this.boundaryLength - 2 - } - - get isFinished(): boolean { - return this.partIndex === this.partIndexToLength.length - } - - // noinspection JSUnusedGlobalSymbols - _write(data: Buffer, encoding: string, callback: (error?: Error) => void): void { - if (this.isFinished) { - console.error(`Trailing ignored data: ${data.length} bytes`) - return - } - - this.handleData(data).then(callback).catch(callback) - } - - private async handleData(chunk: Buffer): Promise { - let start = 0 - - if (this.ignoreByteCount !== 0 && this.remainingPartDataCount !== 0) { - throw newError("Internal error", "ERR_DATA_SPLITTER_BYTE_COUNT_MISMATCH") - } - - if (this.ignoreByteCount > 0) { - const toIgnore = Math.min(this.ignoreByteCount, chunk.length) - this.ignoreByteCount -= toIgnore - start = toIgnore - } else if (this.remainingPartDataCount > 0) { - const toRead = Math.min(this.remainingPartDataCount, chunk.length) - this.remainingPartDataCount -= toRead - await this.processPartData(chunk, 0, toRead) - start = toRead - } - - if (start === chunk.length) { - return - } - - if (this.readState === ReadState.HEADER) { - const headerListEnd = this.searchHeaderListEnd(chunk, start) - if (headerListEnd === -1) { - return - } - - start = headerListEnd - this.readState = ReadState.BODY - // header list is ignored, we don't need it - this.headerListBuffer = null - } - - while (true) { - if (this.readState === ReadState.BODY) { - this.readState = ReadState.INIT - } else { - this.partIndex++ - - let taskIndex = this.partIndexToTaskIndex.get(this.partIndex) - if (taskIndex == null) { - if (this.isFinished) { - taskIndex = this.options.end - } else { - throw newError("taskIndex is null", "ERR_DATA_SPLITTER_TASK_INDEX_IS_NULL") - } - } - - const prevTaskIndex = this.partIndex === 0 ? this.options.start : this.partIndexToTaskIndex.get(this.partIndex - 1)! + 1 /* prev part is download, next maybe copy */ - if (prevTaskIndex < taskIndex) { - await this.copyExistingData(prevTaskIndex, taskIndex) - } else if (prevTaskIndex > taskIndex) { - throw newError("prevTaskIndex must be < taskIndex", "ERR_DATA_SPLITTER_TASK_INDEX_ASSERT_FAILED") - } - - if (this.isFinished) { - this.onPartEnd() - this.finishHandler() - return - } - - start = this.searchHeaderListEnd(chunk, start) - - if (start === -1) { - this.readState = ReadState.HEADER - return - } - } - - const partLength = this.partIndexToLength[this.partIndex] - const end = start + partLength - const effectiveEnd = Math.min(end, chunk.length) - await this.processPartStarted(chunk, start, effectiveEnd) - this.remainingPartDataCount = partLength - (effectiveEnd - start) - if (this.remainingPartDataCount > 0) { - return - } - - start = end + this.boundaryLength - if (start >= chunk.length) { - this.ignoreByteCount = this.boundaryLength - (chunk.length - end) - return - } - } - } - - private copyExistingData(index: number, end: number): Promise { - return new Promise((resolve, reject) => { - const w = (): void => { - if (index === end) { - resolve() - return - } - - const task = this.options.tasks[index] - if (task.kind !== OperationKind.COPY) { - reject(new Error("Task kind must be COPY")) - return - } - - copyData(task, this.out, this.options.oldFileFd, reject, () => { - index++ - w() - }) - } - w() - }) - } - - private searchHeaderListEnd(chunk: Buffer, readOffset: number): number { - const headerListEnd = chunk.indexOf(DOUBLE_CRLF, readOffset) - if (headerListEnd !== -1) { - return headerListEnd + DOUBLE_CRLF.length - } - - // not all headers data were received, save to buffer - const partialChunk = readOffset === 0 ? chunk : chunk.slice(readOffset) - if (this.headerListBuffer == null) { - this.headerListBuffer = partialChunk - } else { - this.headerListBuffer = Buffer.concat([this.headerListBuffer, partialChunk]) - } - return -1 - } - - private actualPartLength = 0 - - private onPartEnd(): void { - const expectedLength = this.partIndexToLength[this.partIndex - 1] - if (this.actualPartLength !== expectedLength) { - throw newError(`Expected length: ${expectedLength} differs from actual: ${this.actualPartLength}`, "ERR_DATA_SPLITTER_LENGTH_MISMATCH") - } - this.actualPartLength = 0 - } - - private processPartStarted(data: Buffer, start: number, end: number): Promise { - if (this.partIndex !== 0) { - this.onPartEnd() - } - return this.processPartData(data, start, end) - } - - private processPartData(data: Buffer, start: number, end: number): Promise { - this.actualPartLength += end - start - const out = this.out - if (out.write(start === 0 && data.length === end ? data : data.slice(start, end))) { - return Promise.resolve() - } else { - return new Promise((resolve, reject) => { - out.on("error", reject) - out.once("drain", () => { - out.removeListener("error", reject) - resolve() - }) - }) - } - } -} diff --git a/src/main/electron-updater/differentialDownloader/DifferentialDownloader.ts b/src/main/electron-updater/differentialDownloader/DifferentialDownloader.ts deleted file mode 100644 index dea2348..0000000 --- a/src/main/electron-updater/differentialDownloader/DifferentialDownloader.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { BlockMapDataHolder, createHttpError, DigestTransform, HttpExecutor, configureRequestUrl, configureRequestOptions } from "builder-util-runtime" -import { BlockMap } from "builder-util-runtime/out/blockMapApi" -import { close, open } from "fs-extra" -import { createWriteStream } from "fs" -import { OutgoingHttpHeaders, RequestOptions } from "http" -import { ProgressInfo, CancellationToken } from "builder-util-runtime" -import { Logger } from "../main" -import { copyData } from "./DataSplitter" -import { URL } from "url" -import { computeOperations, Operation, OperationKind } from "./downloadPlanBuilder" -import { checkIsRangesSupported, executeTasksUsingMultipleRangeRequests } from "./multipleRangeDownloader" -import { ProgressDifferentialDownloadCallbackTransform, ProgressDifferentialDownloadInfo } from "./ProgressDifferentialDownloadCallbackTransform" - -export interface DifferentialDownloaderOptions { - readonly oldFile: string - readonly newUrl: URL - readonly logger: Logger - readonly newFile: string - - readonly requestHeaders: OutgoingHttpHeaders | null - - readonly isUseMultipleRangeRequest?: boolean - - readonly cancellationToken: CancellationToken - onProgress?: (progress: ProgressInfo) => void -} - -export abstract class DifferentialDownloader { - fileMetadataBuffer: Buffer | null = null - - private readonly logger: Logger - - // noinspection TypeScriptAbstractClassConstructorCanBeMadeProtected - constructor( - protected readonly blockAwareFileInfo: BlockMapDataHolder, - readonly httpExecutor: HttpExecutor, - readonly options: DifferentialDownloaderOptions - ) { - this.logger = options.logger - } - - createRequestOptions(): RequestOptions { - const result = { - headers: { - ...this.options.requestHeaders, - accept: "*/*", - }, - } - configureRequestUrl(this.options.newUrl, result) - // user-agent, cache-control and other common options - configureRequestOptions(result) - return result - } - - protected doDownload(oldBlockMap: BlockMap, newBlockMap: BlockMap): Promise { - // we don't check other metadata like compressionMethod - generic check that it is make sense to differentially update is suitable for it - if (oldBlockMap.version !== newBlockMap.version) { - throw new Error(`version is different (${oldBlockMap.version} - ${newBlockMap.version}), full download is required`) - } - - const logger = this.logger - const operations = computeOperations(oldBlockMap, newBlockMap, logger) - if (logger.debug != null) { - logger.debug(JSON.stringify(operations, null, 2)) - } - - let downloadSize = 0 - let copySize = 0 - for (const operation of operations) { - const length = operation.end - operation.start - if (operation.kind === OperationKind.DOWNLOAD) { - downloadSize += length - } else { - copySize += length - } - } - - const newSize = this.blockAwareFileInfo.size - if (downloadSize + copySize + (this.fileMetadataBuffer == null ? 0 : this.fileMetadataBuffer.length) !== newSize) { - throw new Error(`Internal error, size mismatch: downloadSize: ${downloadSize}, copySize: ${copySize}, newSize: ${newSize}`) - } - - logger.info(`Full: ${formatBytes(newSize)}, To download: ${formatBytes(downloadSize)} (${Math.round(downloadSize / (newSize / 100))}%)`) - - return this.downloadFile(operations) - } - - private downloadFile(tasks: Array): Promise { - const fdList: Array = [] - const closeFiles = (): Promise> => { - return Promise.all( - fdList.map(openedFile => { - return close(openedFile.descriptor).catch((e: any) => { - this.logger.error(`cannot close file "${openedFile.path}": ${e}`) - }) - }) - ) - } - return this.doDownloadFile(tasks, fdList) - .then(closeFiles) - .catch((e: any) => { - // then must be after catch here (since then always throws error) - return closeFiles() - .catch(closeFilesError => { - // closeFiles never throw error, but just to be sure - try { - this.logger.error(`cannot close files: ${closeFilesError}`) - } catch (errorOnLog) { - try { - console.error(errorOnLog) - } catch (ignored) { - // ok, give up and ignore error - } - } - throw e - }) - .then(() => { - throw e - }) - }) - } - - private async doDownloadFile(tasks: Array, fdList: Array): Promise { - const oldFileFd = await open(this.options.oldFile, "r") - fdList.push({ descriptor: oldFileFd, path: this.options.oldFile }) - const newFileFd = await open(this.options.newFile, "w") - fdList.push({ descriptor: newFileFd, path: this.options.newFile }) - const fileOut = createWriteStream(this.options.newFile, { fd: newFileFd }) - await new Promise((resolve, reject) => { - const streams: Array = [] - - // Create our download info transformer if we have one - let downloadInfoTransform: ProgressDifferentialDownloadCallbackTransform | undefined = undefined - if (!this.options.isUseMultipleRangeRequest && this.options.onProgress) { - // TODO: Does not support multiple ranges (someone feel free to PR this!) - const expectedByteCounts: Array = [] - let grandTotalBytes = 0 - - for (const task of tasks) { - if (task.kind === OperationKind.DOWNLOAD) { - expectedByteCounts.push(task.end - task.start) - grandTotalBytes += task.end - task.start - } - } - - const progressDifferentialDownloadInfo: ProgressDifferentialDownloadInfo = { - expectedByteCounts: expectedByteCounts, - grandTotal: grandTotalBytes, - } - - downloadInfoTransform = new ProgressDifferentialDownloadCallbackTransform(progressDifferentialDownloadInfo, this.options.cancellationToken, this.options.onProgress) - streams.push(downloadInfoTransform) - } - - const digestTransform = new DigestTransform(this.blockAwareFileInfo.sha512) - // to simply debug, do manual validation to allow file to be fully written - digestTransform.isValidateOnEnd = false - streams.push(digestTransform) - - // noinspection JSArrowFunctionCanBeReplacedWithShorthand - fileOut.on("finish", () => { - ;(fileOut.close as any)(() => { - // remove from fd list because closed successfully - fdList.splice(1, 1) - try { - digestTransform.validate() - } catch (e: any) { - reject(e) - return - } - - resolve(undefined) - }) - }) - - streams.push(fileOut) - - let lastStream = null - for (const stream of streams) { - stream.on("error", reject) - if (lastStream == null) { - lastStream = stream - } else { - lastStream = lastStream.pipe(stream) - } - } - - const firstStream = streams[0] - - let w: any - if (this.options.isUseMultipleRangeRequest) { - w = executeTasksUsingMultipleRangeRequests(this, tasks, firstStream, oldFileFd, reject) - w(0) - return - } - - let downloadOperationCount = 0 - let actualUrl: string | null = null - this.logger.info(`Differential download: ${this.options.newUrl}`) - - const requestOptions = this.createRequestOptions() - ;(requestOptions as any).redirect = "manual" - - w = (index: number): void => { - if (index >= tasks.length) { - if (this.fileMetadataBuffer != null) { - firstStream.write(this.fileMetadataBuffer) - } - firstStream.end() - return - } - - const operation = tasks[index++] - if (operation.kind === OperationKind.COPY) { - // We are copying, let's not send status updates to the UI - if (downloadInfoTransform) { - downloadInfoTransform.beginFileCopy() - } - - copyData(operation, firstStream, oldFileFd, reject, () => w(index)) - return - } - - const range = `bytes=${operation.start}-${operation.end - 1}` - requestOptions.headers!.range = range - - this.logger?.debug?.(`download range: ${range}`) - - // We are starting to download - if (downloadInfoTransform) { - downloadInfoTransform.beginRangeDownload() - } - - const request = this.httpExecutor.createRequest(requestOptions, response => { - response.on("error", reject) - response.on("abort", () => { - reject(new Error("response has been aborted by the server")) - }) - // Electron net handles redirects automatically, our NodeJS test server doesn't use redirects - so, we don't check 3xx codes. - if (response.statusCode >= 400) { - reject(createHttpError(response)) - } - - response.pipe(firstStream, { - end: false, - }) - response.once("end", () => { - // Pass on that we are downloading a segment - if (downloadInfoTransform) { - downloadInfoTransform.endRangeDownload() - } - - if (++downloadOperationCount === 100) { - downloadOperationCount = 0 - setTimeout(() => w(index), 1000) - } else { - w(index) - } - }) - }) - request.on("redirect", (statusCode: number, method: string, redirectUrl: string) => { - this.logger.info(`Redirect to ${removeQuery(redirectUrl)}`) - actualUrl = redirectUrl - configureRequestUrl(new URL(actualUrl), requestOptions) - request.followRedirect() - }) - this.httpExecutor.addErrorAndTimeoutHandlers(request, reject) - request.end() - } - - w(0) - }) - } - - protected async readRemoteBytes(start: number, endInclusive: number): Promise { - const buffer = Buffer.allocUnsafe(endInclusive + 1 - start) - const requestOptions = this.createRequestOptions() - requestOptions.headers!.range = `bytes=${start}-${endInclusive}` - let position = 0 - await this.request(requestOptions, chunk => { - chunk.copy(buffer, position) - position += chunk.length - }) - - if (position !== buffer.length) { - throw new Error(`Received data length ${position} is not equal to expected ${buffer.length}`) - } - return buffer - } - - private request(requestOptions: RequestOptions, dataHandler: (chunk: Buffer) => void): Promise { - return new Promise((resolve, reject) => { - const request = this.httpExecutor.createRequest(requestOptions, response => { - if (!checkIsRangesSupported(response, reject)) { - return - } - - response.on("data", dataHandler) - response.on("end", () => resolve()) - }) - this.httpExecutor.addErrorAndTimeoutHandlers(request, reject) - request.end() - }) - } -} - -function formatBytes(value: number, symbol = " KB"): string { - return new Intl.NumberFormat("en").format((value / 1024).toFixed(2) as any) + symbol -} - -// safety -function removeQuery(url: string): string { - const index = url.indexOf("?") - return index < 0 ? url : url.substring(0, index) -} - -interface OpenedFile { - readonly descriptor: number - readonly path: string -} diff --git a/src/main/electron-updater/differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader.ts b/src/main/electron-updater/differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader.ts deleted file mode 100644 index d4446db..0000000 --- a/src/main/electron-updater/differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BlockMap } from "builder-util-runtime/out/blockMapApi" -import { close, fstat, open, read } from "fs-extra" -import { DifferentialDownloader } from "./DifferentialDownloader" -import { inflateRawSync } from "zlib" - -export class FileWithEmbeddedBlockMapDifferentialDownloader extends DifferentialDownloader { - async download(): Promise { - const packageInfo = this.blockAwareFileInfo - const fileSize = packageInfo.size! - const offset = fileSize - (packageInfo.blockMapSize! + 4) - this.fileMetadataBuffer = await this.readRemoteBytes(offset, fileSize - 1) - const newBlockMap = readBlockMap(this.fileMetadataBuffer.slice(0, this.fileMetadataBuffer.length - 4)) - await this.doDownload(await readEmbeddedBlockMapData(this.options.oldFile), newBlockMap) - } -} - -function readBlockMap(data: Buffer): BlockMap { - return JSON.parse(inflateRawSync(data).toString()) -} - -async function readEmbeddedBlockMapData(file: string): Promise { - const fd = await open(file, "r") - try { - const fileSize = (await fstat(fd)).size - const sizeBuffer = Buffer.allocUnsafe(4) - await read(fd, sizeBuffer, 0, sizeBuffer.length, fileSize - sizeBuffer.length) - - const dataBuffer = Buffer.allocUnsafe(sizeBuffer.readUInt32BE(0)) - await read(fd, dataBuffer, 0, dataBuffer.length, fileSize - sizeBuffer.length - dataBuffer.length) - await close(fd) - - return readBlockMap(dataBuffer) - } catch (e: any) { - await close(fd) - throw e - } -} diff --git a/src/main/electron-updater/differentialDownloader/GenericDifferentialDownloader.ts b/src/main/electron-updater/differentialDownloader/GenericDifferentialDownloader.ts deleted file mode 100644 index b7727d6..0000000 --- a/src/main/electron-updater/differentialDownloader/GenericDifferentialDownloader.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BlockMap } from "builder-util-runtime/out/blockMapApi" -import { DifferentialDownloader } from "./DifferentialDownloader" - -export class GenericDifferentialDownloader extends DifferentialDownloader { - download(oldBlockMap: BlockMap, newBlockMap: BlockMap): Promise { - return this.doDownload(oldBlockMap, newBlockMap) - } -} diff --git a/src/main/electron-updater/differentialDownloader/ProgressDifferentialDownloadCallbackTransform.ts b/src/main/electron-updater/differentialDownloader/ProgressDifferentialDownloadCallbackTransform.ts deleted file mode 100644 index 8e51a60..0000000 --- a/src/main/electron-updater/differentialDownloader/ProgressDifferentialDownloadCallbackTransform.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Transform } from "stream" -import { CancellationToken } from "builder-util-runtime" - -enum OperationKind { - COPY, - DOWNLOAD, -} - -export interface ProgressInfo { - total: number - delta: number - transferred: number - percent: number - bytesPerSecond: number -} - -export interface ProgressDifferentialDownloadInfo { - expectedByteCounts: Array - grandTotal: number -} - -export class ProgressDifferentialDownloadCallbackTransform extends Transform { - private start = Date.now() - private transferred = 0 - private delta = 0 - private expectedBytes = 0 - private index = 0 - private operationType = OperationKind.COPY - - private nextUpdate = this.start + 1000 - - constructor( - private readonly progressDifferentialDownloadInfo: ProgressDifferentialDownloadInfo, - private readonly cancellationToken: CancellationToken, - private readonly onProgress: (info: ProgressInfo) => any - ) { - super() - } - - _transform(chunk: any, encoding: string, callback: any) { - if (this.cancellationToken.cancelled) { - callback(new Error("cancelled"), null) - return - } - - // Don't send progress update when copying from disk - if (this.operationType == OperationKind.COPY) { - callback(null, chunk) - return - } - - this.transferred += chunk.length - this.delta += chunk.length - - const now = Date.now() - if ( - now >= this.nextUpdate && - this.transferred !== this.expectedBytes /* will be emitted by endRangeDownload() */ && - this.transferred !== this.progressDifferentialDownloadInfo.grandTotal /* will be emitted on _flush */ - ) { - this.nextUpdate = now + 1000 - - this.onProgress({ - total: this.progressDifferentialDownloadInfo.grandTotal, - delta: this.delta, - transferred: this.transferred, - percent: (this.transferred / this.progressDifferentialDownloadInfo.grandTotal) * 100, - bytesPerSecond: Math.round(this.transferred / ((now - this.start) / 1000)), - }) - this.delta = 0 - } - - callback(null, chunk) - } - - beginFileCopy(): void { - this.operationType = OperationKind.COPY - } - - beginRangeDownload(): void { - this.operationType = OperationKind.DOWNLOAD - - this.expectedBytes += this.progressDifferentialDownloadInfo.expectedByteCounts[this.index++] - } - - endRangeDownload(): void { - // _flush() will doour final 100% - if (this.transferred !== this.progressDifferentialDownloadInfo.grandTotal) { - this.onProgress({ - total: this.progressDifferentialDownloadInfo.grandTotal, - delta: this.delta, - transferred: this.transferred, - percent: (this.transferred / this.progressDifferentialDownloadInfo.grandTotal) * 100, - bytesPerSecond: Math.round(this.transferred / ((Date.now() - this.start) / 1000)), - }) - } - } - - // Called when we are 100% done with the connection/download - _flush(callback: any): void { - if (this.cancellationToken.cancelled) { - callback(new Error("cancelled")) - return - } - - this.onProgress({ - total: this.progressDifferentialDownloadInfo.grandTotal, - delta: this.delta, - transferred: this.transferred, - percent: 100, - bytesPerSecond: Math.round(this.transferred / ((Date.now() - this.start) / 1000)), - }) - this.delta = 0 - this.transferred = 0 - - callback(null) - } -} diff --git a/src/main/electron-updater/differentialDownloader/downloadPlanBuilder.ts b/src/main/electron-updater/differentialDownloader/downloadPlanBuilder.ts deleted file mode 100644 index c4d6f17..0000000 --- a/src/main/electron-updater/differentialDownloader/downloadPlanBuilder.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { BlockMap, BlockMapFile } from "builder-util-runtime/out/blockMapApi" -import { Logger } from "../main" - -export enum OperationKind { - COPY, - DOWNLOAD, -} - -export interface Operation { - kind: OperationKind - - // inclusive - start: number - // exclusive - end: number - - // debug only - // oldBlocks: Array | null -} - -export function computeOperations(oldBlockMap: BlockMap, newBlockMap: BlockMap, logger: Logger): Array { - const nameToOldBlocks = buildBlockFileMap(oldBlockMap.files) - const nameToNewBlocks = buildBlockFileMap(newBlockMap.files) - - let lastOperation: Operation | null = null - - // for now only one file is supported in block map - const blockMapFile: { name: string; offset: number } = newBlockMap.files[0] - const operations: Array = [] - const name = blockMapFile.name - const oldEntry = nameToOldBlocks.get(name) - if (oldEntry == null) { - // new file (unrealistic case for now, because in any case both blockmap contain the only file named as "file") - throw new Error(`no file ${name} in old blockmap`) - } - - const newFile = nameToNewBlocks.get(name)! - let changedBlockCount = 0 - - const { checksumToOffset: checksumToOldOffset, checksumToOldSize } = buildChecksumMap(nameToOldBlocks.get(name)!, oldEntry.offset, logger) - - let newOffset = blockMapFile.offset - for (let i = 0; i < newFile.checksums.length; newOffset += newFile.sizes[i], i++) { - const blockSize: number = newFile.sizes[i] - const checksum = newFile.checksums[i] - let oldOffset = checksumToOldOffset.get(checksum) - if (oldOffset != null && checksumToOldSize.get(checksum) !== blockSize) { - logger.warn(`Checksum ("${checksum}") matches, but size differs (old: ${checksumToOldSize.get(checksum)}, new: ${blockSize})`) - oldOffset = undefined - } - - if (oldOffset === undefined) { - // download data from new file - changedBlockCount++ - - if (lastOperation != null && lastOperation.kind === OperationKind.DOWNLOAD && lastOperation.end === newOffset) { - lastOperation.end += blockSize - } else { - lastOperation = { - kind: OperationKind.DOWNLOAD, - start: newOffset, - end: newOffset + blockSize, - // oldBlocks: null, - } - validateAndAdd(lastOperation, operations, checksum, i) - } - } else { - // reuse data from old file - if (lastOperation != null && lastOperation.kind === OperationKind.COPY && lastOperation.end === oldOffset) { - lastOperation.end += blockSize - // lastOperation.oldBlocks!!.push(checksum) - } else { - lastOperation = { - kind: OperationKind.COPY, - start: oldOffset, - end: oldOffset + blockSize, - // oldBlocks: [checksum] - } - validateAndAdd(lastOperation, operations, checksum, i) - } - } - } - - if (changedBlockCount > 0) { - logger.info(`File${blockMapFile.name === "file" ? "" : " " + blockMapFile.name} has ${changedBlockCount} changed blocks`) - } - return operations -} - -const isValidateOperationRange = process.env["DIFFERENTIAL_DOWNLOAD_PLAN_BUILDER_VALIDATE_RANGES"] === "true" - -function validateAndAdd(operation: Operation, operations: Array, checksum: string, index: number): void { - if (isValidateOperationRange && operations.length !== 0) { - const lastOperation = operations[operations.length - 1] - if (lastOperation.kind === operation.kind && operation.start < lastOperation.end && operation.start > lastOperation.start) { - const min = [lastOperation.start, lastOperation.end, operation.start, operation.end].reduce((p, v) => (p < v ? p : v)) - throw new Error( - `operation (block index: ${index}, checksum: ${checksum}, kind: ${OperationKind[operation.kind]}) overlaps previous operation (checksum: ${checksum}):\n` + - `abs: ${lastOperation.start} until ${lastOperation.end} and ${operation.start} until ${operation.end}\n` + - `rel: ${lastOperation.start - min} until ${lastOperation.end - min} and ${operation.start - min} until ${operation.end - min}` - ) - } - } - operations.push(operation) -} - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function buildChecksumMap(file: BlockMapFile, fileOffset: number, logger: Logger) { - const checksumToOffset = new Map() - const checksumToSize = new Map() - let offset = fileOffset - for (let i = 0; i < file.checksums.length; i++) { - const checksum = file.checksums[i] - const size = file.sizes[i] - - const existing = checksumToSize.get(checksum) - if (existing === undefined) { - checksumToOffset.set(checksum, offset) - checksumToSize.set(checksum, size) - } else if (logger.debug != null) { - const sizeExplanation = existing === size ? "(same size)" : `(size: ${existing}, this size: ${size})` - logger.debug(`${checksum} duplicated in blockmap ${sizeExplanation}, it doesn't lead to broken differential downloader, just corresponding block will be skipped)`) - } - offset += size - } - return { checksumToOffset, checksumToOldSize: checksumToSize } -} - -function buildBlockFileMap(list: Array): Map { - const result = new Map() - for (const item of list) { - result.set(item.name, item) - } - return result -} diff --git a/src/main/electron-updater/differentialDownloader/multipleRangeDownloader.ts b/src/main/electron-updater/differentialDownloader/multipleRangeDownloader.ts deleted file mode 100644 index c8973c8..0000000 --- a/src/main/electron-updater/differentialDownloader/multipleRangeDownloader.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createHttpError, safeGetHeader } from "builder-util-runtime" -import { IncomingMessage } from "http" -import { Writable } from "stream" -import { copyData, DataSplitter, PartListDataTask } from "./DataSplitter" -import { DifferentialDownloader } from "./DifferentialDownloader" -import { Operation, OperationKind } from "./downloadPlanBuilder" - -export function executeTasksUsingMultipleRangeRequests( - differentialDownloader: DifferentialDownloader, - tasks: Array, - out: Writable, - oldFileFd: number, - reject: (error: Error) => void -): (taskOffset: number) => void { - const w = (taskOffset: number): void => { - if (taskOffset >= tasks.length) { - if (differentialDownloader.fileMetadataBuffer != null) { - out.write(differentialDownloader.fileMetadataBuffer) - } - out.end() - return - } - - const nextOffset = taskOffset + 1000 - doExecuteTasks( - differentialDownloader, - { - tasks, - start: taskOffset, - end: Math.min(tasks.length, nextOffset), - oldFileFd, - }, - out, - () => w(nextOffset), - reject - ) - } - return w -} - -function doExecuteTasks(differentialDownloader: DifferentialDownloader, options: PartListDataTask, out: Writable, resolve: () => void, reject: (error: Error) => void): void { - let ranges = "bytes=" - let partCount = 0 - const partIndexToTaskIndex = new Map() - const partIndexToLength: Array = [] - for (let i = options.start; i < options.end; i++) { - const task = options.tasks[i] - if (task.kind === OperationKind.DOWNLOAD) { - ranges += `${task.start}-${task.end - 1}, ` - partIndexToTaskIndex.set(partCount, i) - partCount++ - partIndexToLength.push(task.end - task.start) - } - } - - if (partCount <= 1) { - // the only remote range - copy - const w = (index: number): void => { - if (index >= options.end) { - resolve() - return - } - - const task = options.tasks[index++] - - if (task.kind === OperationKind.COPY) { - copyData(task, out, options.oldFileFd, reject, () => w(index)) - } else { - const requestOptions = differentialDownloader.createRequestOptions() - requestOptions.headers!.Range = `bytes=${task.start}-${task.end - 1}` - const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => { - if (!checkIsRangesSupported(response, reject)) { - return - } - - response.pipe(out, { - end: false, - }) - response.once("end", () => w(index)) - }) - differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject) - request.end() - } - } - - w(options.start) - return - } - - const requestOptions = differentialDownloader.createRequestOptions() - requestOptions.headers!.Range = ranges.substring(0, ranges.length - 2) - const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => { - if (!checkIsRangesSupported(response, reject)) { - return - } - - const contentType = safeGetHeader(response, "content-type") - const m = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i.exec(contentType) - if (m == null) { - reject(new Error(`Content-Type "multipart/byteranges" is expected, but got "${contentType}"`)) - return - } - - const dicer = new DataSplitter(out, options, partIndexToTaskIndex, m[1] || m[2], partIndexToLength, resolve) - dicer.on("error", reject) - response.pipe(dicer) - - response.on("end", () => { - setTimeout(() => { - request.abort() - reject(new Error("Response ends without calling any handlers")) - }, 10000) - }) - }) - differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject) - request.end() -} - -export function checkIsRangesSupported(response: IncomingMessage, reject: (error: Error) => void): boolean { - // Electron net handles redirects automatically, our NodeJS test server doesn't use redirects - so, we don't check 3xx codes. - if (response.statusCode! >= 400) { - reject(createHttpError(response)) - return false - } - - if (response.statusCode !== 206) { - const acceptRanges = safeGetHeader(response, "accept-ranges") - if (acceptRanges == null || acceptRanges === "none") { - reject(new Error(`Server doesn't support Accept-Ranges (response code ${response.statusCode})`)) - return false - } - } - return true -} diff --git a/src/main/electron-updater/electronHttpExecutor.ts b/src/main/electron-updater/electronHttpExecutor.ts deleted file mode 100644 index f60af8e..0000000 --- a/src/main/electron-updater/electronHttpExecutor.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { DownloadOptions, HttpExecutor, configureRequestOptions, configureRequestUrl } from "builder-util-runtime" -import { AuthInfo } from "electron" -import { RequestOptions } from "http" -import Session = Electron.Session -import ClientRequest = Electron.ClientRequest - -export type LoginCallback = (username: string, password: string) => void -export const NET_SESSION_NAME = "electron-updater" - -export function getNetSession(): Session { - return require("electron").session.fromPartition(NET_SESSION_NAME, { - cache: false, - }) -} - -export class ElectronHttpExecutor extends HttpExecutor { - private cachedSession: Session | null = null - - constructor(private readonly proxyLoginCallback?: (authInfo: AuthInfo, callback: LoginCallback) => void) { - super() - } - - async download(url: URL, destination: string, options: DownloadOptions): Promise { - return await options.cancellationToken.createPromise((resolve, reject, onCancel) => { - const requestOptions = { - headers: options.headers || undefined, - redirect: "manual", - } - configureRequestUrl(url, requestOptions) - configureRequestOptions(requestOptions) - this.doDownload( - requestOptions, - { - destination, - options, - onCancel, - callback: error => { - if (error == null) { - resolve(destination) - } else { - reject(error) - } - }, - responseHandler: null, - }, - 0 - ) - }) - } - - createRequest(options: any, callback: (response: any) => void): Electron.ClientRequest { - // fix (node 7+) for making electron updater work when using AWS private buckets, check if headers contain Host property - if (options.headers && options.headers.Host) { - // set host value from headers.Host - options.host = options.headers.Host - // remove header property 'Host', if not removed causes net::ERR_INVALID_ARGUMENT exception - delete options.headers.Host - } - - // differential downloader can call this method very often, so, better to cache session - if (this.cachedSession == null) { - this.cachedSession = getNetSession() - } - - const request = require("electron").net.request({ - ...options, - session: this.cachedSession, - }) as Electron.ClientRequest - request.on("response", callback) - if (this.proxyLoginCallback != null) { - request.on("login", this.proxyLoginCallback) - } - return request - } - - protected addRedirectHandlers( - request: ClientRequest, - options: RequestOptions, - reject: (error: Error) => void, - redirectCount: number, - handler: (options: RequestOptions) => void - ): void { - request.on("redirect", (statusCode: number, method: string, redirectUrl: string) => { - // no way to modify request options, abort old and make a new one - // https://github.com/electron/electron/issues/11505 - request.abort() - - if (redirectCount > this.maxRedirects) { - reject(this.createMaxRedirectError()) - } else { - handler(HttpExecutor.prepareRedirectUrlOptions(redirectUrl, options)) - } - }) - } -} diff --git a/src/main/electron-updater/main.ts b/src/main/electron-updater/main.ts deleted file mode 100644 index 7b7e8d5..0000000 --- a/src/main/electron-updater/main.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { CancellationToken, PackageFileInfo, ProgressInfo, UpdateFileInfo, UpdateInfo } from "builder-util-runtime" -import { EventEmitter } from "events" -import { existsSync, readFileSync } from "fs-extra" -import * as path from "path" -import { URL } from "url" -import { AppUpdater } from "./AppUpdater" -import { LoginCallback } from "./electronHttpExecutor" - -export { BaseUpdater } from "./BaseUpdater" -export { AppUpdater, NoOpLogger } from "./AppUpdater" -export { CancellationToken, PackageFileInfo, ProgressInfo, UpdateFileInfo, UpdateInfo } -export { Provider } from "./providers/Provider" -export { AppImageUpdater } from "./AppImageUpdater" -export { DebUpdater } from "./DebUpdater" -export { RpmUpdater } from "./RpmUpdater" -export { MacUpdater } from "./MacUpdater" -export { NsisUpdater } from "./NsisUpdater" - -// autoUpdater to mimic electron bundled autoUpdater -let _autoUpdater: any - -// required for jsdoc -export declare const autoUpdater: AppUpdater - -function doLoadAutoUpdater(): AppUpdater { - // tslint:disable:prefer-conditional-expression - if (process.platform === "win32") { - _autoUpdater = new (require("./NsisUpdater").NsisUpdater)() - } else if (process.platform === "darwin") { - _autoUpdater = new (require("./MacUpdater").MacUpdater)() - } else { - _autoUpdater = new (require("./AppImageUpdater").AppImageUpdater)() - try { - const identity = path.join(process.resourcesPath!, "package-type") - if (!existsSync(identity)) { - return _autoUpdater - } - console.info("Checking for beta autoupdate feature for deb/rpm distributions") - const fileType = readFileSync(identity).toString().trim() - console.info("Found package-type:", fileType) - switch (fileType) { - case "deb": - _autoUpdater = new (require("./DebUpdater").DebUpdater)() - break - case "rpm": - _autoUpdater = new (require("./RpmUpdater").RpmUpdater)() - break - default: - break - } - } catch (error: any) { - console.warn( - "Unable to detect 'package-type' for autoUpdater (beta rpm/deb support). If you'd like to expand support, please consider contributing to electron-builder", - error.message - ) - } - } - return _autoUpdater -} - -Object.defineProperty(exports, "autoUpdater", { - enumerable: true, - get: () => { - return _autoUpdater || doLoadAutoUpdater() - }, -}) - -export interface ResolvedUpdateFileInfo { - readonly url: URL - readonly info: UpdateFileInfo - - packageInfo?: PackageFileInfo -} - -export interface UpdateCheckResult { - readonly updateInfo: UpdateInfo - - readonly downloadPromise?: Promise> | null - - readonly cancellationToken?: CancellationToken - - /** @deprecated */ - readonly versionInfo: UpdateInfo -} - -export type UpdaterEvents = "login" | "checking-for-update" | "update-available" | "update-not-available" | "update-cancelled" | "download-progress" | "update-downloaded" | "error" - -export const DOWNLOAD_PROGRESS = "download-progress" -export const UPDATE_DOWNLOADED = "update-downloaded" - -export type LoginHandler = (authInfo: any, callback: LoginCallback) => void - -export class UpdaterSignal { - constructor(private emitter: EventEmitter) {} - - /** - * Emitted when an authenticating proxy is [asking for user credentials](https://github.com/electron/electron/blob/master/docs/api/client-request.md#event-login). - */ - login(handler: LoginHandler): void { - addHandler(this.emitter, "login", handler) - } - - progress(handler: (info: ProgressInfo) => void): void { - addHandler(this.emitter, DOWNLOAD_PROGRESS, handler) - } - - updateDownloaded(handler: (info: UpdateDownloadedEvent) => void): void { - addHandler(this.emitter, UPDATE_DOWNLOADED, handler) - } - - updateCancelled(handler: (info: UpdateInfo) => void): void { - addHandler(this.emitter, "update-cancelled", handler) - } -} - -export interface UpdateDownloadedEvent extends UpdateInfo { - downloadedFile: string -} - -const isLogEvent = false - -function addHandler(emitter: EventEmitter, event: UpdaterEvents, handler: (...args: Array) => void): void { - if (isLogEvent) { - emitter.on(event, (...args: Array) => { - console.log("%s %s", event, args) - handler(...args) - }) - } else { - emitter.on(event, handler) - } -} - -export interface Logger { - info(message?: any): void - - warn(message?: any): void - - error(message?: any): void - - debug?(message: string): void -} - -// return null if verify signature succeed -// return error message if verify signature failed - -export type verifyUpdateCodeSignature = (publisherName: string[], path: string) => Promise diff --git a/src/main/electron-updater/providerFactory.ts b/src/main/electron-updater/providerFactory.ts deleted file mode 100644 index 21f6b22..0000000 --- a/src/main/electron-updater/providerFactory.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - AllPublishOptions, - BaseS3Options, - BitbucketOptions, - CustomPublishOptions, - GenericServerOptions, - getS3LikeProviderBaseUrl, - GithubOptions, - KeygenOptions, - newError, - PublishConfiguration, -} from "builder-util-runtime" -import { AppUpdater } from "./AppUpdater" -import { BitbucketProvider } from "./providers/BitbucketProvider" -import { GenericProvider } from "./providers/GenericProvider" -import { GitHubProvider } from "./providers/GitHubProvider" -import { KeygenProvider } from "./providers/KeygenProvider" -import { PrivateGitHubProvider } from "./providers/PrivateGitHubProvider" -import { Provider, ProviderRuntimeOptions } from "./providers/Provider" - -export function isUrlProbablySupportMultiRangeRequests(url: string): boolean { - return !url.includes("s3.amazonaws.com") -} - -export function createClient(data: PublishConfiguration | AllPublishOptions, updater: AppUpdater, runtimeOptions: ProviderRuntimeOptions): Provider { - // noinspection SuspiciousTypeOfGuard - if (typeof data === "string") { - throw newError("Please pass PublishConfiguration object", "ERR_UPDATER_INVALID_PROVIDER_CONFIGURATION") - } - - const provider = data.provider - switch (provider) { - case "github": { - const githubOptions = data as GithubOptions - const token = (githubOptions.private ? process.env["GH_TOKEN"] || process.env["GITHUB_TOKEN"] : null) || githubOptions.token - if (token == null) { - return new GitHubProvider(githubOptions, updater, runtimeOptions) - } else { - return new PrivateGitHubProvider(githubOptions, updater, token, runtimeOptions) - } - } - - case "bitbucket": - return new BitbucketProvider(data as BitbucketOptions, updater, runtimeOptions) - - case "keygen": - return new KeygenProvider(data as KeygenOptions, updater, runtimeOptions) - - case "s3": - case "spaces": - return new GenericProvider( - { - provider: "generic", - url: getS3LikeProviderBaseUrl(data), - channel: (data as BaseS3Options).channel || null, - }, - updater, - { - ...runtimeOptions, - // https://github.com/minio/minio/issues/5285#issuecomment-350428955 - isUseMultipleRangeRequest: false, - } - ) - - case "generic": { - const options = data as GenericServerOptions - return new GenericProvider(options, updater, { - ...runtimeOptions, - isUseMultipleRangeRequest: options.useMultipleRangeRequest !== false && isUrlProbablySupportMultiRangeRequests(options.url), - }) - } - - case "custom": { - const options = data as CustomPublishOptions - const constructor = options.updateProvider - if (!constructor) { - throw newError("Custom provider not specified", "ERR_UPDATER_INVALID_PROVIDER_CONFIGURATION") - } - return new constructor(options, updater, runtimeOptions) - } - - default: - throw newError(`Unsupported provider: ${provider}`, "ERR_UPDATER_UNSUPPORTED_PROVIDER") - } -} diff --git a/src/main/electron-updater/providers/BitbucketProvider.ts b/src/main/electron-updater/providers/BitbucketProvider.ts deleted file mode 100644 index 2ff2063..0000000 --- a/src/main/electron-updater/providers/BitbucketProvider.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { CancellationToken, BitbucketOptions, newError, UpdateInfo } from "builder-util-runtime" -import { AppUpdater } from "../AppUpdater" -import { ResolvedUpdateFileInfo } from "../main" -import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" -import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" - -export class BitbucketProvider extends Provider { - private readonly baseUrl: URL - - constructor( - private readonly configuration: BitbucketOptions, - private readonly updater: AppUpdater, - runtimeOptions: ProviderRuntimeOptions - ) { - super({ - ...runtimeOptions, - isUseMultipleRangeRequest: false, - }) - const { owner, slug } = configuration - this.baseUrl = newBaseUrl(`https://api.bitbucket.org/2.0/repositories/${owner}/${slug}/downloads`) - } - - private get channel(): string { - return this.updater.channel || this.configuration.channel || "latest" - } - - async getLatestVersion(): Promise { - const cancellationToken = new CancellationToken() - const channelFile = getChannelFilename(this.getCustomChannelName(this.channel)) - const channelUrl = newUrlFromBase(channelFile, this.baseUrl, this.updater.isAddNoCacheQuery) - try { - const updateInfo = await this.httpRequest(channelUrl, undefined, cancellationToken) - return parseUpdateInfo(updateInfo, channelFile, channelUrl) - } catch (e: any) { - throw newError(`Unable to find latest version on ${this.toString()}, please ensure release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND") - } - } - - resolveFiles(updateInfo: UpdateInfo): Array { - return resolveFiles(updateInfo, this.baseUrl) - } - - toString() { - const { owner, slug } = this.configuration - return `Bitbucket (owner: ${owner}, slug: ${slug}, channel: ${this.channel})` - } -} diff --git a/src/main/electron-updater/providers/GenericProvider.ts b/src/main/electron-updater/providers/GenericProvider.ts deleted file mode 100644 index aa1dbed..0000000 --- a/src/main/electron-updater/providers/GenericProvider.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { GenericServerOptions, HttpError, newError, UpdateInfo } from "builder-util-runtime" -import { AppUpdater } from "../AppUpdater" -import { ResolvedUpdateFileInfo } from "../main" -import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" -import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" - -export class GenericProvider extends Provider { - private readonly baseUrl = newBaseUrl(this.configuration.url) - - constructor( - private readonly configuration: GenericServerOptions, - private readonly updater: AppUpdater, - runtimeOptions: ProviderRuntimeOptions - ) { - super(runtimeOptions) - } - - private get channel(): string { - const result = this.updater.channel || this.configuration.channel - return result == null ? this.getDefaultChannelName() : this.getCustomChannelName(result) - } - - async getLatestVersion(): Promise { - const channelFile = getChannelFilename(this.channel) - const channelUrl = newUrlFromBase(channelFile, this.baseUrl, this.updater.isAddNoCacheQuery) - for (let attemptNumber = 0; ; attemptNumber++) { - try { - return parseUpdateInfo(await this.httpRequest(channelUrl), channelFile, channelUrl) - } catch (e: any) { - if (e instanceof HttpError && e.statusCode === 404) { - throw newError(`Cannot find channel "${channelFile}" update info: ${e.stack || e.message}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND") - } else if (e.code === "ECONNREFUSED") { - if (attemptNumber < 3) { - await new Promise((resolve, reject) => { - try { - setTimeout(resolve, 1000 * attemptNumber) - } catch (e: any) { - reject(e) - } - }) - continue - } - } - throw e - } - } - } - - resolveFiles(updateInfo: UpdateInfo): Array { - return resolveFiles(updateInfo, this.baseUrl) - } -} diff --git a/src/main/electron-updater/providers/GitHubProvider.ts b/src/main/electron-updater/providers/GitHubProvider.ts deleted file mode 100644 index 1b15b6f..0000000 --- a/src/main/electron-updater/providers/GitHubProvider.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { CancellationToken, GithubOptions, githubUrl, HttpError, newError, parseXml, ReleaseNoteInfo, UpdateInfo, XElement } from "builder-util-runtime" -import * as semver from "semver" -import { URL } from "url" -import { AppUpdater } from "../AppUpdater" -import { ResolvedUpdateFileInfo } from "../main" -import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" -import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" - -const hrefRegExp = /\/tag\/([^/]+)$/ - -interface GithubUpdateInfo extends UpdateInfo { - tag: string -} -export abstract class BaseGitHubProvider extends Provider { - // so, we don't need to parse port (because node http doesn't support host as url does) - protected readonly baseUrl: URL - protected readonly baseApiUrl: URL - - protected constructor( - protected readonly options: GithubOptions, - defaultHost: string, - runtimeOptions: ProviderRuntimeOptions - ) { - super({ - ...runtimeOptions, - /* because GitHib uses S3 */ - isUseMultipleRangeRequest: false, - }) - - this.baseUrl = newBaseUrl(githubUrl(options, defaultHost)) - const apiHost = defaultHost === "github.com" ? "api.github.com" : defaultHost - this.baseApiUrl = newBaseUrl(githubUrl(options, apiHost)) - } - - protected computeGithubBasePath(result: string): string { - // https://github.com/electron-userland/electron-builder/issues/1903#issuecomment-320881211 - const host = this.options.host - return host && !["github.com", "api.github.com"].includes(host) ? `/api/v3${result}` : result - } -} - -export class GitHubProvider extends BaseGitHubProvider { - constructor( - protected readonly options: GithubOptions, - private readonly updater: AppUpdater, - runtimeOptions: ProviderRuntimeOptions - ) { - super(options, "github.com", runtimeOptions) - } - - async getLatestVersion(): Promise { - const cancellationToken = new CancellationToken() - - const feedXml: string = (await this.httpRequest( - newUrlFromBase(`${this.basePath}.atom`, this.baseUrl), - { - accept: "application/xml, application/atom+xml, text/xml, */*", - }, - cancellationToken - ))! - - const feed = parseXml(feedXml) - // noinspection TypeScriptValidateJSTypes - let latestRelease = feed.element("entry", false, `No published versions on GitHub`) - let tag: string | null = null - try { - if (this.updater.allowPrerelease) { - const currentChannel = this.updater?.channel || (semver.prerelease(this.updater.currentVersion)?.[0] as string) || null - - if (currentChannel === null) { - // noinspection TypeScriptValidateJSTypes - tag = hrefRegExp.exec(latestRelease.element("link").attribute("href"))![1] - } else { - for (const element of feed.getElements("entry")) { - // noinspection TypeScriptValidateJSTypes - const hrefElement = hrefRegExp.exec(element.element("link").attribute("href"))! - - // If this is null then something is wrong and skip this release - if (hrefElement === null) continue - - // This Release's Tag - const hrefTag = hrefElement[1] - //Get Channel from this release's tag - const hrefChannel = (semver.prerelease(hrefTag)?.[0] as string) || null - - const shouldFetchVersion = !currentChannel || ["alpha", "beta"].includes(currentChannel) - const isCustomChannel = hrefChannel !== null && !["alpha", "beta"].includes(String(hrefChannel)) - // Allow moving from alpha to beta but not down - const channelMismatch = currentChannel === "beta" && hrefChannel === "alpha" - - if (shouldFetchVersion && !isCustomChannel && !channelMismatch) { - tag = hrefTag - break - } - - const isNextPreRelease = hrefChannel && hrefChannel === currentChannel - if (isNextPreRelease) { - tag = hrefTag - break - } - } - } - } else { - tag = await this.getLatestTagName(cancellationToken) - for (const element of feed.getElements("entry")) { - // noinspection TypeScriptValidateJSTypes - if (hrefRegExp.exec(element.element("link").attribute("href"))![1] === tag) { - latestRelease = element - break - } - } - } - } catch (e: any) { - throw newError(`Cannot parse releases feed: ${e.stack || e.message},\nXML:\n${feedXml}`, "ERR_UPDATER_INVALID_RELEASE_FEED") - } - - if (tag == null) { - throw newError(`No published versions on GitHub`, "ERR_UPDATER_NO_PUBLISHED_VERSIONS") - } - - let rawData: string - let channelFile = "" - let channelFileUrl: any = "" - const fetchData = async (channelName: string) => { - channelFile = getChannelFilename(channelName) - channelFileUrl = newUrlFromBase(this.getBaseDownloadPath(String(tag), channelFile), this.baseUrl) - const requestOptions = this.createRequestOptions(channelFileUrl) - try { - return (await this.executor.request(requestOptions, cancellationToken))! - } catch (e: any) { - if (e instanceof HttpError && e.statusCode === 404) { - throw newError(`Cannot find ${channelFile} in the latest release artifacts (${channelFileUrl}): ${e.stack || e.message}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND") - } - throw e - } - } - - try { - const channel = this.updater.allowPrerelease ? this.getCustomChannelName(String(semver.prerelease(tag)?.[0] || "latest")) : this.getDefaultChannelName() - rawData = await fetchData(channel) - } catch (e: any) { - if (this.updater.allowPrerelease) { - // Allow fallback to `latest.yml` - rawData = await fetchData(this.getDefaultChannelName()) - } else { - throw e - } - } - - const result = parseUpdateInfo(rawData, channelFile, channelFileUrl) - if (result.releaseName == null) { - result.releaseName = latestRelease.elementValueOrEmpty("title") - } - - if (result.releaseNotes == null) { - result.releaseNotes = computeReleaseNotes(this.updater.currentVersion, this.updater.fullChangelog, feed, latestRelease) - } - return { - tag: tag, - ...result, - } - } - - private async getLatestTagName(cancellationToken: CancellationToken): Promise { - const options = this.options - // do not use API for GitHub to avoid limit, only for custom host or GitHub Enterprise - const url = - options.host == null || options.host === "github.com" - ? newUrlFromBase(`${this.basePath}/latest`, this.baseUrl) - : new URL(`${this.computeGithubBasePath(`/repos/${options.owner}/${options.repo}/releases`)}/latest`, this.baseApiUrl) - try { - const rawData = await this.httpRequest(url, { Accept: "application/json" }, cancellationToken) - if (rawData == null) { - return null - } - - const releaseInfo: GithubReleaseInfo = JSON.parse(rawData) - return releaseInfo.tag_name - } catch (e: any) { - throw newError(`Unable to find latest version on GitHub (${url}), please ensure a production release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND") - } - } - - private get basePath(): string { - return `/${this.options.owner}/${this.options.repo}/releases` - } - - resolveFiles(updateInfo: GithubUpdateInfo): Array { - // still replace space to - due to backward compatibility - return resolveFiles(updateInfo, this.baseUrl, p => this.getBaseDownloadPath(updateInfo.tag, p.replace(/ /g, "-"))) - } - - private getBaseDownloadPath(tag: string, fileName: string): string { - return `${this.basePath}/download/${tag}/${fileName}` - } -} - -interface GithubReleaseInfo { - readonly tag_name: string -} - -function getNoteValue(parent: XElement): string { - const result = parent.elementValueOrEmpty("content") - // GitHub reports empty notes as No content. - return result === "No content." ? "" : result -} - -export function computeReleaseNotes(currentVersion: semver.SemVer, isFullChangelog: boolean, feed: XElement, latestRelease: any): string | Array | null { - if (!isFullChangelog) { - return getNoteValue(latestRelease) - } - - const releaseNotes: Array = [] - for (const release of feed.getElements("entry")) { - // noinspection TypeScriptValidateJSTypes - const versionRelease = /\/tag\/v?([^/]+)$/.exec(release.element("link").attribute("href"))![1] - if (semver.lt(currentVersion, versionRelease)) { - releaseNotes.push({ - version: versionRelease, - note: getNoteValue(release), - }) - } - } - return releaseNotes.sort((a, b) => semver.rcompare(a.version, b.version)) -} diff --git a/src/main/electron-updater/providers/KeygenProvider.ts b/src/main/electron-updater/providers/KeygenProvider.ts deleted file mode 100644 index 3e07fc3..0000000 --- a/src/main/electron-updater/providers/KeygenProvider.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { CancellationToken, KeygenOptions, newError, UpdateInfo } from "builder-util-runtime" -import { AppUpdater } from "../AppUpdater" -import { ResolvedUpdateFileInfo } from "../main" -import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" -import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" - -export class KeygenProvider extends Provider { - private readonly baseUrl: URL - - constructor( - private readonly configuration: KeygenOptions, - private readonly updater: AppUpdater, - runtimeOptions: ProviderRuntimeOptions - ) { - super({ - ...runtimeOptions, - isUseMultipleRangeRequest: false, - }) - this.baseUrl = newBaseUrl(`https://api.keygen.sh/v1/accounts/${this.configuration.account}/artifacts?product=${this.configuration.product}`) - } - - private get channel(): string { - return this.updater.channel || this.configuration.channel || "stable" - } - - async getLatestVersion(): Promise { - const cancellationToken = new CancellationToken() - const channelFile = getChannelFilename(this.getCustomChannelName(this.channel)) - const channelUrl = newUrlFromBase(channelFile, this.baseUrl, this.updater.isAddNoCacheQuery) - try { - const updateInfo = await this.httpRequest( - channelUrl, - { - Accept: "application/vnd.api+json", - "Keygen-Version": "1.1", - }, - cancellationToken - ) - return parseUpdateInfo(updateInfo, channelFile, channelUrl) - } catch (e: any) { - throw newError(`Unable to find latest version on ${this.toString()}, please ensure release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND") - } - } - - resolveFiles(updateInfo: UpdateInfo): Array { - return resolveFiles(updateInfo, this.baseUrl) - } - - toString() { - const { account, product, platform } = this.configuration - return `Keygen (account: ${account}, product: ${product}, platform: ${platform}, channel: ${this.channel})` - } -} diff --git a/src/main/electron-updater/providers/PrivateGitHubProvider.ts b/src/main/electron-updater/providers/PrivateGitHubProvider.ts deleted file mode 100644 index 94558de..0000000 --- a/src/main/electron-updater/providers/PrivateGitHubProvider.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { CancellationToken, GithubOptions, HttpError, newError, UpdateInfo } from "builder-util-runtime" -import { OutgoingHttpHeaders, RequestOptions } from "http" -import { load } from "js-yaml" -import * as path from "path" -import { AppUpdater } from "../AppUpdater" -import { URL } from "url" -import { getChannelFilename, newUrlFromBase } from "../util" -import { BaseGitHubProvider } from "./GitHubProvider" -import { ResolvedUpdateFileInfo } from "../main" -import { getFileList, ProviderRuntimeOptions } from "./Provider" - -export interface PrivateGitHubUpdateInfo extends UpdateInfo { - assets: Array -} - -export class PrivateGitHubProvider extends BaseGitHubProvider { - constructor( - options: GithubOptions, - private readonly updater: AppUpdater, - private readonly token: string, - runtimeOptions: ProviderRuntimeOptions - ) { - super(options, "api.github.com", runtimeOptions) - } - - protected createRequestOptions(url: URL, headers?: OutgoingHttpHeaders | null): RequestOptions { - const result = super.createRequestOptions(url, headers) - ;(result as any).redirect = "manual" - return result - } - - async getLatestVersion(): Promise { - const cancellationToken = new CancellationToken() - const channelFile = getChannelFilename(this.getDefaultChannelName()) - - const releaseInfo = await this.getLatestVersionInfo(cancellationToken) - const asset = releaseInfo.assets.find(it => it.name === channelFile) - if (asset == null) { - // html_url must be always, but just to be sure - throw newError(`Cannot find ${channelFile} in the release ${releaseInfo.html_url || releaseInfo.name}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND") - } - - const url = new URL(asset.url) - let result: any - try { - result = load((await this.httpRequest(url, this.configureHeaders("application/octet-stream"), cancellationToken))!) - } catch (e: any) { - if (e instanceof HttpError && e.statusCode === 404) { - throw newError(`Cannot find ${channelFile} in the latest release artifacts (${url}): ${e.stack || e.message}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND") - } - throw e - } - - ;(result as PrivateGitHubUpdateInfo).assets = releaseInfo.assets - return result - } - - get fileExtraDownloadHeaders(): OutgoingHttpHeaders | null { - return this.configureHeaders("application/octet-stream") - } - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - private configureHeaders(accept: string) { - return { - accept, - authorization: `token ${this.token}`, - } - } - - private async getLatestVersionInfo(cancellationToken: CancellationToken): Promise { - const allowPrerelease = this.updater.allowPrerelease - let basePath = this.basePath - if (!allowPrerelease) { - basePath = `${basePath}/latest` - } - - const url = newUrlFromBase(basePath, this.baseUrl) - try { - const version = JSON.parse((await this.httpRequest(url, this.configureHeaders("application/vnd.github.v3+json"), cancellationToken))!) - if (allowPrerelease) { - return (version as Array<{ prerelease: boolean }>).find(it => it.prerelease) || version[0] - } else { - return version - } - } catch (e: any) { - throw newError(`Unable to find latest version on GitHub (${url}), please ensure a production release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND") - } - } - - private get basePath(): string { - return this.computeGithubBasePath(`/repos/${this.options.owner}/${this.options.repo}/releases`) - } - - resolveFiles(updateInfo: PrivateGitHubUpdateInfo): Array { - return getFileList(updateInfo).map(it => { - const name = path.posix.basename(it.url).replace(/ /g, "-") - const asset = updateInfo.assets.find(it => it != null && it.name === name) - if (asset == null) { - throw newError(`Cannot find asset "${name}" in: ${JSON.stringify(updateInfo.assets, null, 2)}`, "ERR_UPDATER_ASSET_NOT_FOUND") - } - - return { - url: new URL(asset.url), - info: it, - } - }) - } -} - -interface ReleaseInfo { - name: string - html_url: string - assets: Array -} - -export interface Asset { - name: string - url: string -} diff --git a/src/main/electron-updater/providers/Provider.ts b/src/main/electron-updater/providers/Provider.ts deleted file mode 100644 index 37f6502..0000000 --- a/src/main/electron-updater/providers/Provider.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { CancellationToken, configureRequestUrl, newError, safeStringifyJson, UpdateFileInfo, UpdateInfo, WindowsUpdateInfo } from "builder-util-runtime" -import { OutgoingHttpHeaders, RequestOptions } from "http" -import { load } from "js-yaml" -import { URL } from "url" -import { ElectronHttpExecutor } from "../electronHttpExecutor" -import { ResolvedUpdateFileInfo } from "../main" -import { newUrlFromBase } from "../util" - -export type ProviderPlatform = "darwin" | "linux" | "win32" - -export interface ProviderRuntimeOptions { - isUseMultipleRangeRequest: boolean - platform: ProviderPlatform - - executor: ElectronHttpExecutor -} - -export abstract class Provider { - private requestHeaders: OutgoingHttpHeaders | null = null - protected readonly executor: ElectronHttpExecutor - - protected constructor(private readonly runtimeOptions: ProviderRuntimeOptions) { - this.executor = runtimeOptions.executor - } - - get isUseMultipleRangeRequest(): boolean { - return this.runtimeOptions.isUseMultipleRangeRequest !== false - } - - private getChannelFilePrefix(): string { - if (this.runtimeOptions.platform === "linux") { - const arch = process.env["TEST_UPDATER_ARCH"] || process.arch - const archSuffix = arch === "x64" ? "" : `-${arch}` - return "-linux" + archSuffix - } else { - return this.runtimeOptions.platform === "darwin" ? "-mac" : "" - } - } - - // due to historical reasons for windows we use channel name without platform specifier - protected getDefaultChannelName(): string { - return this.getCustomChannelName("latest") - } - - protected getCustomChannelName(channel: string): string { - return `${channel}${this.getChannelFilePrefix()}` - } - - get fileExtraDownloadHeaders(): OutgoingHttpHeaders | null { - return null - } - - setRequestHeaders(value: OutgoingHttpHeaders | null): void { - this.requestHeaders = value - } - - abstract getLatestVersion(): Promise - - abstract resolveFiles(updateInfo: T): Array - - /** - * Method to perform API request only to resolve update info, but not to download update. - */ - protected httpRequest(url: URL, headers?: OutgoingHttpHeaders | null, cancellationToken?: CancellationToken): Promise { - return this.executor.request(this.createRequestOptions(url, headers), cancellationToken) - } - - protected createRequestOptions(url: URL, headers?: OutgoingHttpHeaders | null): RequestOptions { - const result: RequestOptions = {} - if (this.requestHeaders == null) { - if (headers != null) { - result.headers = headers - } - } else { - result.headers = headers == null ? this.requestHeaders : { ...this.requestHeaders, ...headers } - } - - configureRequestUrl(url, result) - return result - } -} - -export function findFile(files: Array, extension: string, not?: Array): ResolvedUpdateFileInfo | null | undefined { - if (files.length === 0) { - throw newError("No files provided", "ERR_UPDATER_NO_FILES_PROVIDED") - } - - const result = files.find(it => it.url.pathname.toLowerCase().endsWith(`.${extension}`)) - if (result != null) { - return result - } else if (not == null) { - return files[0] - } else { - return files.find(fileInfo => !not.some(ext => fileInfo.url.pathname.toLowerCase().endsWith(`.${ext}`))) - } -} - -export function parseUpdateInfo(rawData: string | null, channelFile: string, channelFileUrl: URL): UpdateInfo { - if (rawData == null) { - throw newError(`Cannot parse update info from ${channelFile} in the latest release artifacts (${channelFileUrl}): rawData: null`, "ERR_UPDATER_INVALID_UPDATE_INFO") - } - - let result: UpdateInfo - try { - result = load(rawData) as UpdateInfo - } catch (e: any) { - throw newError( - `Cannot parse update info from ${channelFile} in the latest release artifacts (${channelFileUrl}): ${e.stack || e.message}, rawData: ${rawData}`, - "ERR_UPDATER_INVALID_UPDATE_INFO" - ) - } - return result -} - -export function getFileList(updateInfo: UpdateInfo): Array { - const files = updateInfo.files - if (files != null && files.length > 0) { - return files - } - - // noinspection JSDeprecatedSymbols - if (updateInfo.path != null) { - // noinspection JSDeprecatedSymbols - return [ - { - url: updateInfo.path, - sha2: (updateInfo as any).sha2, - sha512: updateInfo.sha512, - } as any, - ] - } else { - throw newError(`No files provided: ${safeStringifyJson(updateInfo)}`, "ERR_UPDATER_NO_FILES_PROVIDED") - } -} - -export function resolveFiles(updateInfo: UpdateInfo, baseUrl: URL, pathTransformer: (p: string) => string = (p: string): string => p): Array { - const files = getFileList(updateInfo) - const result: Array = files.map(fileInfo => { - if ((fileInfo as any).sha2 == null && fileInfo.sha512 == null) { - throw newError(`Update info doesn't contain nor sha256 neither sha512 checksum: ${safeStringifyJson(fileInfo)}`, "ERR_UPDATER_NO_CHECKSUM") - } - return { - url: newUrlFromBase(pathTransformer(fileInfo.url), baseUrl), - info: fileInfo, - } - }) - - const packages = (updateInfo as WindowsUpdateInfo).packages - const packageInfo = packages == null ? null : packages[process.arch] || packages.ia32 - if (packageInfo != null) { - ;(result[0] as any).packageInfo = { - ...packageInfo, - path: newUrlFromBase(pathTransformer(packageInfo.path), baseUrl).href, - } - } - return result -} diff --git a/src/main/electron-updater/util.ts b/src/main/electron-updater/util.ts deleted file mode 100644 index 2216e3a..0000000 --- a/src/main/electron-updater/util.ts +++ /dev/null @@ -1,37 +0,0 @@ -// if baseUrl path doesn't ends with /, this path will be not prepended to passed pathname for new URL(input, base) -import { URL } from "url" -// @ts-ignore -import * as escapeRegExp from "lodash.escaperegexp" - -/** @internal */ -export function newBaseUrl(url: string): URL { - const result = new URL(url) - if (!result.pathname.endsWith("/")) { - result.pathname += "/" - } - return result -} - -// addRandomQueryToAvoidCaching is false by default because in most cases URL already contains version number, -// so, it makes sense only for Generic Provider for channel files -export function newUrlFromBase(pathname: string, baseUrl: URL, addRandomQueryToAvoidCaching = false): URL { - const result = new URL(pathname, baseUrl) - // search is not propagated (search is an empty string if not specified) - const search = baseUrl.search - if (search != null && search.length !== 0) { - result.search = search - } else if (addRandomQueryToAvoidCaching) { - result.search = `noCache=${Date.now().toString(32)}` - } - return result -} - -export function getChannelFilename(channel: string): string { - return `${channel}.yml` -} - -export function blockmapFiles(baseUrl: URL, oldVersion: string, newVersion: string): URL[] { - const newBlockMapUrl = newUrlFromBase(`${baseUrl.pathname}.blockmap`, baseUrl) - const oldBlockMapUrl = newUrlFromBase(`${baseUrl.pathname.replace(new RegExp(escapeRegExp(newVersion), "g"), oldVersion)}.blockmap`, baseUrl) - return [oldBlockMapUrl, newBlockMapUrl] -} diff --git a/src/main/electron-updater/windowsExecutableCodeSignatureVerifier.ts b/src/main/electron-updater/windowsExecutableCodeSignatureVerifier.ts deleted file mode 100644 index ab52ef6..0000000 --- a/src/main/electron-updater/windowsExecutableCodeSignatureVerifier.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { parseDn } from "builder-util-runtime" -import { execFile, execFileSync } from "child_process" -import * as os from "os" -import { Logger } from "./main" - -// $certificateInfo = (Get-AuthenticodeSignature 'xxx\yyy.exe' -// | where {$_.Status.Equals([System.Management.Automation.SignatureStatus]::Valid) -and $_.SignerCertificate.Subject.Contains("CN=siemens.com")}) -// | Out-String ; if ($certificateInfo) { exit 0 } else { exit 1 } -export function verifySignature(publisherNames: Array, unescapedTempUpdateFile: string, logger: Logger): Promise { - return new Promise((resolve, reject) => { - // Escape quotes and backticks in filenames to prevent user from breaking the - // arguments and perform a remote command injection. - // - // Consider example powershell command: - // ```powershell - // Get-AuthenticodeSignature 'C:\\path\\my-bad-';calc;'filename.exe' - // ``` - // The above would work expected and find the file name, however, it will also execute `;calc;` - // command and start the calculator app. - // - // From Powershell quoting rules: - // https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7 - // * Double quotes `"` are treated literally within single-quoted strings; - // * Single quotes can be escaped by doubling them: 'don''t' -> don't; - // - // Also note that at this point the file has already been written to the disk, thus we are - // guaranteed that the path will not contain any illegal characters like <>:"/\|?* - // https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file - const tempUpdateFile = unescapedTempUpdateFile.replace(/'/g, "''") - logger.info(`Verifying signature ${tempUpdateFile}`) - - // https://github.com/electron-userland/electron-builder/issues/2421 - // https://github.com/electron-userland/electron-builder/issues/2535 - // Resetting PSModulePath is necessary https://github.com/electron-userland/electron-builder/issues/7127 - execFile( - `set "PSModulePath="; chcp 65001 >NUL & powershell.exe`, - ["-NoProfile", "-NonInteractive", "-InputFormat", "None", "-Command", `"Get-AuthenticodeSignature -LiteralPath '${tempUpdateFile}' | ConvertTo-Json -Compress"`], - { - shell: true, - timeout: 20 * 1000, - }, - (error, stdout, stderr) => { - try { - if (error != null || stderr) { - handleError(logger, error, stderr, reject) - resolve(null) - return - } - const data = parseOut(stdout) - if (data.Status === 0) { - const subject = parseDn(data.SignerCertificate.Subject) - let match = false - for (const name of publisherNames) { - const dn = parseDn(name) - if (dn.size) { - // if we have a full DN, compare all values - const allKeys = Array.from(dn.keys()) - match = allKeys.every(key => { - return dn.get(key) === subject.get(key) - }) - } else if (name === subject.get("CN")!) { - logger.warn(`Signature validated using only CN ${name}. Please add your full Distinguished Name (DN) to publisherNames configuration`) - match = true - } - if (match) { - resolve(null) - return - } - } - } - - const result = `publisherNames: ${publisherNames.join(" | ")}, raw info: ` + JSON.stringify(data, (name, value) => (name === "RawData" ? undefined : value), 2) - logger.warn(`Sign verification failed, installer signed with incorrect certificate: ${result}`) - resolve(result) - } catch (e: any) { - handleError(logger, e, null, reject) - resolve(null) - return - } - } - ) - }) -} - -function parseOut(out: string): any { - const data = JSON.parse(out) - delete data.PrivateKey - delete data.IsOSBinary - delete data.SignatureType - const signerCertificate = data.SignerCertificate - if (signerCertificate != null) { - delete signerCertificate.Archived - delete signerCertificate.Extensions - delete signerCertificate.Handle - delete signerCertificate.HasPrivateKey - // duplicates data.SignerCertificate (contains RawData) - delete signerCertificate.SubjectName - } - delete data.Path - return data -} - -function handleError(logger: Logger, error: Error | null, stderr: string | null, reject: (reason: any) => void): void { - if (isOldWin6()) { - logger.warn( - `Cannot execute Get-AuthenticodeSignature: ${error || stderr}. Ignoring signature validation due to unsupported powershell version. Please upgrade to powershell 3 or higher.` - ) - return - } - - try { - execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "ConvertTo-Json test"], { timeout: 10 * 1000 } as any) - } catch (testError: any) { - logger.warn( - `Cannot execute ConvertTo-Json: ${testError.message}. Ignoring signature validation due to unsupported powershell version. Please upgrade to powershell 3 or higher.` - ) - return - } - - if (error != null) { - reject(error) - } - - if (stderr) { - reject(new Error(`Cannot execute Get-AuthenticodeSignature, stderr: ${stderr}. Failing signature validation due to unknown stderr.`)) - } -} - -function isOldWin6(): boolean { - const winVersion = os.release() - return winVersion.startsWith("6.") && !winVersion.startsWith("6.3") -} diff --git a/src/main/nn-auto-updater/custom.ts b/src/main/nn-auto-updater/custom.ts deleted file mode 100644 index e69de29..0000000