From 631e36dc0aea229af269d58742139a51d4e3407c Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 10 Nov 2021 01:56:56 +0100 Subject: [PATCH] Use `invoke`/`handle` in `settingsChannel` --- app/main.ts | 13 +- ts/main/settingsChannel.ts | 180 +++++++------ ts/updater/common.ts | 441 ++++++++++++++++++++++---------- ts/updater/generateKeyPair.ts | 5 +- ts/updater/generateSignature.ts | 5 +- ts/updater/index.ts | 20 +- ts/updater/macos.ts | 354 +++++++------------------ ts/updater/windows.ts | 299 +++++----------------- ts/util/preload.ts | 61 ++--- ts/windows/preload.ts | 29 +-- 10 files changed, 597 insertions(+), 810 deletions(-) diff --git a/app/main.ts b/app/main.ts index f023feddd..fd4900353 100644 --- a/app/main.ts +++ b/app/main.ts @@ -30,6 +30,7 @@ import packageJson from '../package.json'; import * as GlobalErrors from './global_errors'; import { setup as setupSpellChecker } from './spell_check'; import { redactAll, addSensitivePath } from '../ts/util/privacy'; +import { strictAssert } from '../ts/util/assert'; import { consoleLogger } from '../ts/util/consoleLogger'; import './startup_config'; @@ -378,7 +379,9 @@ function handleCommonWindowEvents(window: BrowserWindow) { return; } - window.webContents.send('callbacks:call:persistZoomFactor', [zoomFactor]); + settingsChannel?.invokeCallbackInMainWindow('persistZoomFactor', [ + zoomFactor, + ]); lastZoomFactor = zoomFactor; }; @@ -794,7 +797,11 @@ async function readyForUpdates() { // Second, start checking for app updates try { - await updater.start(getMainWindow, getLogger()); + strictAssert( + settingsChannel !== undefined, + 'SettingsChannel must be initialized' + ); + await updater.start(settingsChannel, getLogger(), getMainWindow); } catch (error) { getLogger().error( 'Error starting update checks:', @@ -1302,7 +1309,7 @@ const onDatabaseError = async (error: string) => { ready = false; if (mainWindow) { - mainWindow.webContents.send('callbacks:call:closeDB', []); + settingsChannel?.invokeCallbackInMainWindow('closeDB', []); mainWindow.close(); } mainWindow = undefined; diff --git a/ts/main/settingsChannel.ts b/ts/main/settingsChannel.ts index b19efa989..e9cc1bffb 100644 --- a/ts/main/settingsChannel.ts +++ b/ts/main/settingsChannel.ts @@ -8,6 +8,7 @@ import { userConfig } from '../../app/user_config'; import { ephemeralConfig } from '../../app/ephemeral_config'; import { installPermissionsHandler } from '../../app/permissions'; import { strictAssert } from '../util/assert'; +import { explodePromise } from '../util/explodePromise'; import type { IPCEventsValuesType, IPCEventsCallbacksType, @@ -18,13 +19,26 @@ const EPHEMERAL_NAME_MAP = new Map([ ['systemTraySetting', 'system-tray-setting'], ]); +type ResponseQueueEntry = Readonly<{ + resolve(value: unknown): void; + reject(error: Error): void; +}>; + export class SettingsChannel { private mainWindow?: BrowserWindow; + private readonly responseQueue = new Map(); + + private responseSeq = 0; + public setMainWindow(mainWindow: BrowserWindow | undefined): void { this.mainWindow = mainWindow; } + public getMainWindow(): BrowserWindow | undefined { + return this.mainWindow; + } + public install(): void { this.installSetting('deviceName', { setter: false }); @@ -91,83 +105,106 @@ export class SettingsChannel { // These ones are different because its single source of truth is userConfig, // not IndexedDB - ipc.on('settings:get:mediaPermissions', event => { - event.sender.send( - 'settings:get-success:mediaPermissions', - null, - userConfig.get('mediaPermissions') || false - ); + ipc.handle('settings:get:mediaPermissions', () => { + return userConfig.get('mediaPermissions') || false; }); - ipc.on('settings:get:mediaCameraPermissions', event => { - event.sender.send( - 'settings:get-success:mediaCameraPermissions', - null, - userConfig.get('mediaCameraPermissions') || false - ); + ipc.handle('settings:get:mediaCameraPermissions', () => { + return userConfig.get('mediaCameraPermissions') || false; }); - ipc.on('settings:set:mediaPermissions', (event, value) => { + ipc.handle('settings:set:mediaPermissions', (_event, value) => { userConfig.set('mediaPermissions', value); // We reinstall permissions handler to ensure that a revoked permission takes effect installPermissionsHandler({ session, userConfig }); - - event.sender.send('settings:set-success:mediaPermissions', null, value); }); - ipc.on('settings:set:mediaCameraPermissions', (event, value) => { + ipc.handle('settings:set:mediaCameraPermissions', (_event, value) => { userConfig.set('mediaCameraPermissions', value); // We reinstall permissions handler to ensure that a revoked permission takes effect installPermissionsHandler({ session, userConfig }); - - event.sender.send( - 'settings:set-success:mediaCameraPermissions', - null, - value - ); }); + + ipc.on('settings:response', (_event, seq, error, value) => { + const entry = this.responseQueue.get(seq); + this.responseQueue.delete(seq); + if (!entry) { + return; + } + + const { resolve, reject } = entry; + if (error) { + reject(error); + } else { + resolve(value); + } + }); + } + + private waitForResponse(): { promise: Promise; seq: number } { + const seq = this.responseSeq; + + // eslint-disable-next-line no-bitwise + this.responseSeq = (this.responseSeq + 1) & 0x7fffffff; + + const { promise, resolve, reject } = explodePromise(); + + this.responseQueue.set(seq, { resolve, reject }); + + return { seq, promise }; } public getSettingFromMainWindow( name: Name ): Promise { const { mainWindow } = this; - return new Promise((resolve, reject) => { - ipc.once(`settings:get-success:${name}`, (_event, error, value) => { - if (error) { - reject(error); - } else { - resolve(value); - } - }); - if (!mainWindow || !mainWindow.webContents) { - reject(new Error('No main window available')); - return; - } - mainWindow.webContents.send(`settings:get:${name}`); - }); + if (!mainWindow || !mainWindow.webContents) { + throw new Error('No main window'); + } + + const { seq, promise } = this.waitForResponse(); + + mainWindow.webContents.send(`settings:get:${name}`, { seq }); + + return promise; + } + + public setSettingInMainWindow( + name: Name, + value: IPCEventsValuesType[Name] + ): Promise { + const { mainWindow } = this; + if (!mainWindow || !mainWindow.webContents) { + throw new Error('No main window'); + } + + const { seq, promise } = this.waitForResponse(); + + mainWindow.webContents.send(`settings:set:${name}`, { seq, value }); + + return promise; + } + + public invokeCallbackInMainWindow( + name: Name, + args: ReadonlyArray + ): Promise { + const { mainWindow } = this; + if (!mainWindow || !mainWindow.webContents) { + throw new Error('Main window not found'); + } + + const { seq, promise } = this.waitForResponse(); + + mainWindow.webContents.send(`settings:call:${name}`, { seq, args }); + + return promise; } private installCallback( name: Name ): void { - ipc.on(`callbacks:call:${name}`, async (event, args) => { - const { mainWindow } = this; - const contents = event.sender; - if (!mainWindow || !mainWindow.webContents) { - return contents.send( - `callbacks:call-success:${name}`, - 'Main window not found' - ); - } - - mainWindow.webContents.send(`callbacks:call:${name}`, args); - ipc.once(`callbacks:call-success:${name}`, (_event, error, value) => { - if (contents.isDestroyed()) { - return; - } - - contents.send(`callbacks:call-success:${name}`, error, value); - }); + ipc.handle(`settings:call:${name}`, async (_event, args) => { + return this.invokeCallbackInMainWindow(name, args); }); } @@ -180,24 +217,8 @@ export class SettingsChannel { }: { getter?: boolean; setter?: boolean; isEphemeral?: boolean } = {} ): void { if (getter) { - ipc.on(`settings:get:${name}`, async event => { - const { mainWindow } = this; - if (mainWindow && mainWindow.webContents) { - let error: Error | undefined; - let value: unknown; - try { - value = await this.getSettingFromMainWindow(name); - } catch (caughtError) { - error = caughtError; - } - - const contents = event.sender; - if (contents.isDestroyed()) { - return; - } - - contents.send(`settings:get-success:${name}`, error, value); - } + ipc.handle(`settings:get:${name}`, async () => { + return this.getSettingFromMainWindow(name); }); } @@ -205,7 +226,7 @@ export class SettingsChannel { return; } - ipc.on(`settings:set:${name}`, (event, value) => { + ipc.handle(`settings:set:${name}`, (_event, value) => { if (isEphemeral) { const ephemeralName = EPHEMERAL_NAME_MAP.get(name); strictAssert( @@ -215,18 +236,7 @@ export class SettingsChannel { ephemeralConfig.set(ephemeralName, value); } - const { mainWindow } = this; - if (mainWindow && mainWindow.webContents) { - ipc.once(`settings:set-success:${name}`, (_event, error) => { - const contents = event.sender; - if (contents.isDestroyed()) { - return; - } - - contents.send(`settings:set-success:${name}`, error); - }); - mainWindow.webContents.send(`settings:set:${name}`, value); - } + return this.setSettingInMainWindow(name, value); }); } } diff --git a/ts/updater/common.ts b/ts/updater/common.ts index 6ba0aafa9..b48702c64 100644 --- a/ts/updater/common.ts +++ b/ts/updater/common.ts @@ -7,7 +7,7 @@ import { statSync, writeFile as writeFileCallback, } from 'fs'; -import { join, normalize } from 'path'; +import { join, normalize, dirname } from 'path'; import { tmpdir } from 'os'; import { throttle } from 'lodash'; @@ -26,14 +26,21 @@ import rimraf from 'rimraf'; import type { BrowserWindow } from 'electron'; import { app, ipcMain } from 'electron'; +import * as durations from '../util/durations'; import { getTempPath } from '../util/attachments'; import { DialogType } from '../types/Dialogs'; +import * as Errors from '../types/errors'; import { getUserAgent } from '../util/getUserAgent'; import { isAlpha, isBeta } from '../util/version'; import * as packageJson from '../../package.json'; -import { getSignatureFileName } from './signature'; +import { + hexToBinary, + verifySignature, + getSignatureFileName, +} from './signature'; import { isPathInside } from '../util/isPathInside'; +import type { SettingsChannel } from '../main/settingsChannel'; import type { LoggerType } from '../types/Logging'; @@ -46,6 +53,8 @@ export const GOT_CONNECT_TIMEOUT = 2 * 60 * 1000; export const GOT_LOOKUP_TIMEOUT = 2 * 60 * 1000; export const GOT_SOCKET_TIMEOUT = 2 * 60 * 1000; +const INTERVAL = 30 * durations.MINUTE; + type JSONUpdateSchema = { version: string; files: Array<{ @@ -59,50 +68,315 @@ type JSONUpdateSchema = { releaseDate: string; }; -export type UpdaterInterface = { - force(): Promise; -}; - export type UpdateInformationType = { fileName: string; size: number; version: string; }; -export async function checkForUpdates( - logger: LoggerType, - forceUpdate = false -): Promise { - const yaml = await getUpdateYaml(); - const parsedYaml = parseYaml(yaml); - const version = getVersion(parsedYaml); +export abstract class Updater { + protected fileName: string | undefined; - if (!version) { - logger.warn('checkForUpdates: no version extracted from downloaded yaml'); + protected version: string | undefined; + + protected updateFilePath: string | undefined; + + constructor( + protected readonly logger: LoggerType, + private readonly settingsChannel: SettingsChannel, + protected readonly getMainWindow: () => BrowserWindow | undefined + ) {} + + // + // Public APIs + // + + public async force(): Promise { + return this.checkForUpdatesMaybeInstall(true); + } + + public async start(): Promise { + this.logger.info('updater/start: starting checks...'); + + app.once('quit', () => this.quitHandler()); + + setInterval(async () => { + try { + await this.checkForUpdatesMaybeInstall(); + } catch (error) { + this.logger.error(`updater/start: ${Errors.toLogFormat(error)}`); + } + }, INTERVAL); + + await this.deletePreviousInstallers(); + await this.checkForUpdatesMaybeInstall(); + } + + public quitHandler(): void { + if (this.updateFilePath) { + this.deleteCache(this.updateFilePath); + } + } + + // + // Abstract methods + // + + protected abstract deletePreviousInstallers(): Promise; + + protected abstract installUpdate(updateFilePath: string): Promise; + + // + // Protected methods + // + + protected setUpdateListener(performUpdateCallback: () => void): void { + ipcMain.removeAllListeners('start-update'); + ipcMain.once('start-update', performUpdateCallback); + } + + // + // Private methods + // + + private async downloadAndInstall( + newFileName: string, + newVersion: string, + updateOnProgress?: boolean + ): Promise { + const { logger } = this; + try { + const oldFileName = this.fileName; + const oldVersion = this.version; + + if (this.updateFilePath) { + this.deleteCache(this.updateFilePath); + } + this.fileName = newFileName; + this.version = newVersion; + + try { + this.updateFilePath = await this.downloadUpdate( + this.fileName, + updateOnProgress + ); + } catch (error) { + // Restore state in case of download error + this.fileName = oldFileName; + this.version = oldVersion; + throw error; + } + + const publicKey = hexToBinary(config.get('updatesPublicKey')); + const verified = await verifySignature( + this.updateFilePath, + this.version, + publicKey + ); + if (!verified) { + // Note: We don't delete the cache here, because we don't want to continually + // re-download the broken release. We will download it only once per launch. + throw new Error( + 'Downloaded update did not pass signature verification ' + + `(version: '${this.version}'; fileName: '${this.fileName}')` + ); + } + + await this.installUpdate(this.updateFilePath); + + const mainWindow = this.getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('show-update-dialog', DialogType.Update, { + version: this.version, + }); + } else { + logger.warn( + 'downloadAndInstall: no mainWindow, cannot show update dialog' + ); + } + } catch (error) { + logger.error(`downloadAndInstall: ${Errors.toLogFormat(error)}`); + } + } + + private async checkForUpdatesMaybeInstall(force = false): Promise { + const { logger } = this; + + logger.info('checkForUpdatesMaybeInstall: checking for update...'); + const result = await this.checkForUpdates(force); + if (!result) { + return; + } + + const { fileName: newFileName, version: newVersion } = result; + + if ( + force || + this.fileName !== newFileName || + !this.version || + gt(newVersion, this.version) + ) { + const autoDownloadUpdates = await this.getAutoDownloadUpdateSetting(); + if (!autoDownloadUpdates) { + this.setUpdateListener(async () => { + logger.info( + 'checkForUpdatesMaybeInstall: have not downloaded update, going to download' + ); + await this.downloadAndInstall(newFileName, newVersion, true); + }); + const mainWindow = this.getMainWindow(); + + if (mainWindow) { + mainWindow.webContents.send( + 'show-update-dialog', + DialogType.DownloadReady, + { + downloadSize: result.size, + version: result.version, + } + ); + } else { + logger.warn( + 'checkForUpdatesMaybeInstall: no mainWindow, cannot show update dialog' + ); + } + return; + } + await this.downloadAndInstall(newFileName, newVersion); + } + } + + private async checkForUpdates( + forceUpdate = false + ): Promise { + const yaml = await getUpdateYaml(); + const parsedYaml = parseYaml(yaml); + const version = getVersion(parsedYaml); + + if (!version) { + this.logger.warn( + 'checkForUpdates: no version extracted from downloaded yaml' + ); + + return null; + } + + if (forceUpdate || isVersionNewer(version)) { + this.logger.info( + `checkForUpdates: found newer version ${version} ` + + `forceUpdate=${forceUpdate}` + ); + + const fileName = getUpdateFileName(parsedYaml); + + return { + fileName, + size: getSize(parsedYaml, fileName), + version, + }; + } + + this.logger.info( + `checkForUpdates: ${version} is not newer; no new update available` + ); return null; } - if (forceUpdate || isVersionNewer(version)) { - logger.info( - `checkForUpdates: found newer version ${version} ` + - `forceUpdate=${forceUpdate}` - ); + private async downloadUpdate( + fileName: string, + updateOnProgress?: boolean + ): Promise { + const baseUrl = getUpdatesBase(); + const updateFileUrl = `${baseUrl}/${fileName}`; - const fileName = getUpdateFileName(parsedYaml); + const signatureFileName = getSignatureFileName(fileName); + const signatureUrl = `${baseUrl}/${signatureFileName}`; - return { - fileName, - size: getSize(parsedYaml, fileName), - version, - }; + let tempDir; + try { + tempDir = await createTempDir(); + const targetUpdatePath = join(tempDir, fileName); + const targetSignaturePath = join(tempDir, getSignatureFileName(fileName)); + + validatePath(tempDir, targetUpdatePath); + validatePath(tempDir, targetSignaturePath); + + this.logger.info(`downloadUpdate: Downloading signature ${signatureUrl}`); + const { body } = await got.get(signatureUrl, getGotOptions()); + await writeFile(targetSignaturePath, body); + + this.logger.info(`downloadUpdate: Downloading update ${updateFileUrl}`); + const downloadStream = got.stream(updateFileUrl, getGotOptions()); + const writeStream = createWriteStream(targetUpdatePath); + + await new Promise((resolve, reject) => { + const mainWindow = this.getMainWindow(); + if (updateOnProgress && mainWindow) { + let downloadedSize = 0; + + const throttledSend = throttle(() => { + mainWindow.webContents.send( + 'show-update-dialog', + DialogType.Downloading, + { downloadedSize } + ); + }, 500); + + downloadStream.on('data', data => { + downloadedSize += data.length; + throttledSend(); + }); + } + + downloadStream.on('error', error => { + reject(error); + }); + downloadStream.on('end', () => { + resolve(); + }); + + writeStream.on('error', error => { + reject(error); + }); + + downloadStream.pipe(writeStream); + }); + + return targetUpdatePath; + } catch (error) { + if (tempDir) { + await deleteTempDir(tempDir); + } + throw error; + } } - logger.info( - `checkForUpdates: ${version} is not newer; no new update available` - ); + private async getAutoDownloadUpdateSetting(): Promise { + try { + return await this.settingsChannel.getSettingFromMainWindow( + 'autoDownloadUpdate' + ); + } catch (error) { + this.logger.warn( + 'getAutoDownloadUpdateSetting: Failed to fetch, returning false', + Errors.toLogFormat(error) + ); + return false; + } + } - return null; + private async deleteCache(filePath: string | null): Promise { + if (!filePath) { + return; + } + const tempDir = dirname(filePath); + try { + await deleteTempDir(tempDir); + } catch (error) { + this.logger.error(`quitHandler: ${Errors.toLogFormat(error)}`); + } + } } export function validatePath(basePath: string, targetPath: string): void { @@ -115,75 +389,6 @@ export function validatePath(basePath: string, targetPath: string): void { } } -export async function downloadUpdate( - fileName: string, - logger: LoggerType, - mainWindow?: BrowserWindow -): Promise { - const baseUrl = getUpdatesBase(); - const updateFileUrl = `${baseUrl}/${fileName}`; - - const signatureFileName = getSignatureFileName(fileName); - const signatureUrl = `${baseUrl}/${signatureFileName}`; - - let tempDir; - try { - tempDir = await createTempDir(); - const targetUpdatePath = join(tempDir, fileName); - const targetSignaturePath = join(tempDir, getSignatureFileName(fileName)); - - validatePath(tempDir, targetUpdatePath); - validatePath(tempDir, targetSignaturePath); - - logger.info(`downloadUpdate: Downloading signature ${signatureUrl}`); - const { body } = await got.get(signatureUrl, getGotOptions()); - await writeFile(targetSignaturePath, body); - - logger.info(`downloadUpdate: Downloading update ${updateFileUrl}`); - const downloadStream = got.stream(updateFileUrl, getGotOptions()); - const writeStream = createWriteStream(targetUpdatePath); - - await new Promise((resolve, reject) => { - if (mainWindow) { - let downloadedSize = 0; - - const throttledSend = throttle(() => { - mainWindow.webContents.send( - 'show-update-dialog', - DialogType.Downloading, - { downloadedSize } - ); - }, 500); - - downloadStream.on('data', data => { - downloadedSize += data.length; - throttledSend(); - }); - } - - downloadStream.on('error', error => { - reject(error); - }); - downloadStream.on('end', () => { - resolve(); - }); - - writeStream.on('error', error => { - reject(error); - }); - - downloadStream.pipe(writeStream); - }); - - return targetUpdatePath; - } catch (error) { - if (tempDir) { - await deleteTempDir(tempDir); - } - throw error; - } -} - // Helper functions export function getUpdateCheckUrl(): string { @@ -338,13 +543,6 @@ export async function deleteTempDir(targetDir: string): Promise { await rimrafPromise(targetDir); } -export function getPrintableError(error: Error | string): Error | string { - if (typeof error === 'string') { - return error; - } - return error && error.stack ? error.stack : error; -} - export function getCliOptions(options: ParserConfiguration['options']): T { const parser = createParser({ options }); const cliOptions = parser.parse(process.argv); @@ -357,34 +555,3 @@ export function getCliOptions(options: ParserConfiguration['options']): T { return (cliOptions as unknown) as T; } - -export function setUpdateListener(performUpdateCallback: () => void): void { - ipcMain.removeAllListeners('start-update'); - ipcMain.once('start-update', performUpdateCallback); -} - -export async function getAutoDownloadUpdateSetting( - mainWindow: BrowserWindow | undefined, - logger: LoggerType -): Promise { - if (!mainWindow) { - logger.warn( - 'getAutoDownloadUpdateSetting: No main window, returning false' - ); - return false; - } - - return new Promise((resolve, reject) => { - ipcMain.once( - 'settings:get-success:autoDownloadUpdate', - (_, error, value: boolean) => { - if (error) { - reject(error); - } else { - resolve(value); - } - } - ); - mainWindow.webContents.send('settings:get:autoDownloadUpdate'); - }); -} diff --git a/ts/updater/generateKeyPair.ts b/ts/updater/generateKeyPair.ts index 89c7b8379..651fd1846 100644 --- a/ts/updater/generateKeyPair.ts +++ b/ts/updater/generateKeyPair.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable no-console */ -import { getCliOptions, getPrintableError } from './common'; +import * as Errors from '../types/errors'; +import { getCliOptions } from './common'; import { keyPair } from './curve'; import { writeHexToPath } from './signature'; @@ -33,7 +34,7 @@ type OptionsType = { const cliOptions = getCliOptions(OPTIONS); go(cliOptions).catch(error => { - console.error('Something went wrong!', getPrintableError(error)); + console.error('Something went wrong!', Errors.toLogFormat(error)); }); async function go(options: OptionsType) { diff --git a/ts/updater/generateSignature.ts b/ts/updater/generateSignature.ts index 5560cece7..277cf735a 100644 --- a/ts/updater/generateSignature.ts +++ b/ts/updater/generateSignature.ts @@ -7,7 +7,8 @@ import { readdir as readdirCallback } from 'fs'; import pify from 'pify'; -import { getCliOptions, getPrintableError } from './common'; +import * as Errors from '../types/errors'; +import { getCliOptions } from './common'; import { writeSignature } from './signature'; import * as packageJson from '../../package.json'; @@ -46,7 +47,7 @@ type OptionsType = { const cliOptions = getCliOptions(OPTIONS); go(cliOptions).catch(error => { - console.error('Something went wrong!', getPrintableError(error)); + console.error('Something went wrong!', Errors.toLogFormat(error)); }); async function go(options: OptionsType) { diff --git a/ts/updater/index.ts b/ts/updater/index.ts index e0cc40f30..c73e77cab 100644 --- a/ts/updater/index.ts +++ b/ts/updater/index.ts @@ -4,18 +4,20 @@ import config from 'config'; import type { BrowserWindow } from 'electron'; -import type { UpdaterInterface } from './common'; -import { start as startMacOS } from './macos'; -import { start as startWindows } from './windows'; +import type { Updater } from './common'; +import { MacOSUpdater } from './macos'; +import { WindowsUpdater } from './windows'; import type { LoggerType } from '../types/Logging'; +import type { SettingsChannel } from '../main/settingsChannel'; let initialized = false; -let updater: UpdaterInterface | undefined; +let updater: Updater | undefined; export async function start( - getMainWindow: () => BrowserWindow | undefined, - logger?: LoggerType + settingsChannel: SettingsChannel, + logger: LoggerType, + getMainWindow: () => BrowserWindow | undefined ): Promise { const { platform } = process; @@ -37,12 +39,14 @@ export async function start( } if (platform === 'win32') { - updater = await startWindows(getMainWindow, logger); + updater = new WindowsUpdater(logger, settingsChannel, getMainWindow); } else if (platform === 'darwin') { - updater = await startMacOS(getMainWindow, logger); + updater = new MacOSUpdater(logger, settingsChannel, getMainWindow); } else { throw new Error('updater/start: Unsupported platform'); } + + await updater.start(); } export async function force(): Promise { diff --git a/ts/updater/macos.ts b/ts/updater/macos.ts index b9dd22da0..d4ea58fa5 100644 --- a/ts/updater/macos.ts +++ b/ts/updater/macos.ts @@ -5,169 +5,31 @@ import { createReadStream, statSync } from 'fs'; import type { IncomingMessage, Server, ServerResponse } from 'http'; import { createServer } from 'http'; import type { AddressInfo } from 'net'; -import { dirname } from 'path'; import { v4 as getGuid } from 'uuid'; -import type { BrowserWindow } from 'electron'; -import { app, autoUpdater } from 'electron'; -import config from 'config'; -import { gt } from 'semver'; +import { autoUpdater } from 'electron'; import got from 'got'; -import type { UpdaterInterface } from './common'; -import { - checkForUpdates, - deleteTempDir, - downloadUpdate, - getAutoDownloadUpdateSetting, - getPrintableError, - setUpdateListener, -} from './common'; -import * as durations from '../util/durations'; -import type { LoggerType } from '../types/Logging'; -import { hexToBinary, verifySignature } from './signature'; +import { Updater } from './common'; +import { explodePromise } from '../util/explodePromise'; +import * as Errors from '../types/errors'; import { markShouldQuit } from '../../app/window_state'; import { DialogType } from '../types/Dialogs'; -const INTERVAL = 30 * durations.MINUTE; - -export async function start( - getMainWindow: () => BrowserWindow | undefined, - logger: LoggerType -): Promise { - logger.info('macos/start: starting checks...'); - - loggerForQuitHandler = logger; - app.once('quit', quitHandler); - - setInterval(async () => { - try { - await checkForUpdatesMaybeInstall(getMainWindow, logger); - } catch (error) { - logger.error(`macos/start: ${getPrintableError(error)}`); - } - }, INTERVAL); - - await checkForUpdatesMaybeInstall(getMainWindow, logger); - - return { - async force(): Promise { - return checkForUpdatesMaybeInstall(getMainWindow, logger, true); - }, - }; -} - -let fileName: string; -let version: string; -let updateFilePath: string; -let loggerForQuitHandler: LoggerType; - -async function checkForUpdatesMaybeInstall( - getMainWindow: () => BrowserWindow | undefined, - logger: LoggerType, - force = false -) { - logger.info('checkForUpdatesMaybeInstall: checking for update...'); - const result = await checkForUpdates(logger, force); - if (!result) { - return; +export class MacOSUpdater extends Updater { + protected async deletePreviousInstallers(): Promise { + // No installers are cache on macOS } - const { fileName: newFileName, version: newVersion } = result; - - if ( - force || - fileName !== newFileName || - !version || - gt(newVersion, version) - ) { - const autoDownloadUpdates = await getAutoDownloadUpdateSetting( - getMainWindow(), - logger - ); - if (!autoDownloadUpdates) { - setUpdateListener(async () => { - logger.info( - 'checkForUpdatesMaybeInstall: have not downloaded update, going to download' - ); - await downloadAndInstall( - newFileName, - newVersion, - getMainWindow, - logger, - true - ); - }); - const mainWindow = getMainWindow(); - - if (mainWindow) { - mainWindow.webContents.send( - 'show-update-dialog', - DialogType.DownloadReady, - { - downloadSize: result.size, - version: result.version, - } - ); - } else { - logger.warn( - 'checkForUpdatesMaybeInstall: no mainWindow, cannot show update dialog' - ); - } - return; - } - await downloadAndInstall(newFileName, newVersion, getMainWindow, logger); - } -} - -async function downloadAndInstall( - newFileName: string, - newVersion: string, - getMainWindow: () => BrowserWindow | undefined, - logger: LoggerType, - updateOnProgress?: boolean -) { - try { - const oldFileName = fileName; - const oldVersion = version; - - deleteCache(updateFilePath, logger); - fileName = newFileName; - version = newVersion; - try { - updateFilePath = await downloadUpdate( - fileName, - logger, - updateOnProgress ? getMainWindow() : undefined - ); - } catch (error) { - // Restore state in case of download error - fileName = oldFileName; - version = oldVersion; - throw error; - } - - if (!updateFilePath) { - logger.info('downloadAndInstall: no update file path. Skipping!'); - return; - } - - const publicKey = hexToBinary(config.get('updatesPublicKey')); - const verified = await verifySignature(updateFilePath, version, publicKey); - if (!verified) { - // Note: We don't delete the cache here, because we don't want to continually - // re-download the broken release. We will download it only once per launch. - throw new Error( - `downloadAndInstall: Downloaded update did not pass signature verification (version: '${version}'; fileName: '${fileName}')` - ); - } + protected async installUpdate(updateFilePath: string): Promise { + const { logger } = this; try { - await handToAutoUpdate(updateFilePath, logger); + await this.handToAutoUpdate(updateFilePath); } catch (error) { const readOnly = 'Cannot update while running on a read-only volume'; const message: string = error.message || ''; - const mainWindow = getMainWindow(); + const mainWindow = this.getMainWindow(); if (mainWindow && message.includes(readOnly)) { logger.info('downloadAndInstall: showing read-only dialog...'); mainWindow.webContents.send( @@ -195,55 +57,25 @@ async function downloadAndInstall( // because Squirrel has cached the update file and will do the right thing. logger.info('downloadAndInstall: showing update dialog...'); - setUpdateListener(() => { + this.setUpdateListener(() => { logger.info('performUpdate: calling quitAndInstall...'); markShouldQuit(); autoUpdater.quitAndInstall(); }); - const mainWindow = getMainWindow(); - - if (mainWindow) { - mainWindow.webContents.send('show-update-dialog', DialogType.Update, { - version, - }); - } else { - logger.warn( - 'checkForUpdatesMaybeInstall: no mainWindow, cannot show update dialog' - ); - } - } catch (error) { - logger.error(`downloadAndInstall: ${getPrintableError(error)}`); } -} -function quitHandler() { - deleteCache(updateFilePath, loggerForQuitHandler); -} + private async handToAutoUpdate(filePath: string): Promise { + const { logger } = this; + const { promise, resolve, reject } = explodePromise(); -// Helpers - -function deleteCache(filePath: string | null, logger: LoggerType) { - if (filePath) { - const tempDir = dirname(filePath); - deleteTempDir(tempDir).catch(error => { - logger.error(`quitHandler: ${getPrintableError(error)}`); - }); - } -} - -async function handToAutoUpdate( - filePath: string, - logger: LoggerType -): Promise { - return new Promise((resolve, reject) => { const token = getGuid(); const updateFileUrl = generateFileUrl(); const server = createServer(); let serverUrl: string; server.on('error', (error: Error) => { - logger.error(`handToAutoUpdate: ${getPrintableError(error)}`); - shutdown(server, logger); + logger.error(`handToAutoUpdate: ${Errors.toLogFormat(error)}`); + this.shutdown(server); reject(error); }); @@ -266,12 +98,15 @@ async function handToAutoUpdate( } if (!url || !url.startsWith(updateFileUrl)) { - write404(url, response, logger); - + this.logger.error( + `write404: Squirrel requested unexpected url '${url}'` + ); + response.writeHead(404); + response.end(); return; } - pipeUpdateToSquirrel(filePath, server, response, logger, reject); + this.pipeUpdateToSquirrel(filePath, server, response, reject); } ); @@ -280,14 +115,14 @@ async function handToAutoUpdate( serverUrl = getServerUrl(server); autoUpdater.on('error', (...args) => { - logger.error('autoUpdater: error', ...args.map(getPrintableError)); + logger.error('autoUpdater: error', ...args.map(Errors.toLogFormat)); const [error] = args; reject(error); }); autoUpdater.on('update-downloaded', () => { logger.info('autoUpdater: update-downloaded event fired'); - shutdown(server, logger); + this.shutdown(server); resolve(); }); @@ -307,46 +142,75 @@ async function handToAutoUpdate( reject(error); } }); - }); + + return promise; + } + + private pipeUpdateToSquirrel( + filePath: string, + server: Server, + response: ServerResponse, + reject: (error: Error) => void + ): void { + const { logger } = this; + + const updateFileSize = getFileSize(filePath); + const readStream = createReadStream(filePath); + + response.on('error', (error: Error) => { + logger.error( + `pipeUpdateToSquirrel: update file download request had an error ${Errors.toLogFormat( + error + )}` + ); + this.shutdown(server); + reject(error); + }); + + readStream.on('error', (error: Error) => { + logger.error( + `pipeUpdateToSquirrel: read stream error response: ${Errors.toLogFormat( + error + )}` + ); + this.shutdown(server, response); + reject(error); + }); + + response.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Length': updateFileSize, + }); + + readStream.pipe(response); + } + + private shutdown(server: Server, response?: ServerResponse): void { + const { logger } = this; + + try { + if (server) { + server.close(); + } + } catch (error) { + logger.error( + `shutdown: Error closing server ${Errors.toLogFormat(error)}` + ); + } + + try { + if (response) { + response.end(); + } + } catch (endError) { + logger.error( + `shutdown: couldn't end response ${Errors.toLogFormat(endError)}` + ); + } + } } -function pipeUpdateToSquirrel( - filePath: string, - server: Server, - response: ServerResponse, - logger: LoggerType, - reject: (error: Error) => void -) { - const updateFileSize = getFileSize(filePath); - const readStream = createReadStream(filePath); - - response.on('error', (error: Error) => { - logger.error( - `pipeUpdateToSquirrel: update file download request had an error ${getPrintableError( - error - )}` - ); - shutdown(server, logger); - reject(error); - }); - - readStream.on('error', (error: Error) => { - logger.error( - `pipeUpdateToSquirrel: read stream error response: ${getPrintableError( - error - )}` - ); - shutdown(server, logger, response); - reject(error); - }); - - response.writeHead(200, { - 'Content-Type': 'application/zip', - 'Content-Length': updateFileSize, - }); - - readStream.pipe(response); -} +// Helpers function writeJSONResponse(url: string, response: ServerResponse) { const data = Buffer.from( @@ -374,16 +238,6 @@ function writeTokenResponse(token: string, response: ServerResponse) { response.end(data); } -function write404( - url: string | undefined, - response: ServerResponse, - logger: LoggerType -) { - logger.error(`write404: Squirrel requested unexpected url '${url}'`); - response.writeHead(404); - response.end(); -} - function getServerUrl(server: Server) { const address = server.address() as AddressInfo; @@ -398,27 +252,3 @@ function getFileSize(targetPath: string): number { return size; } - -function shutdown( - server: Server, - logger: LoggerType, - response?: ServerResponse -) { - try { - if (server) { - server.close(); - } - } catch (error) { - logger.error(`shutdown: Error closing server ${getPrintableError(error)}`); - } - - try { - if (response) { - response.end(); - } - } catch (endError) { - logger.error( - `shutdown: couldn't end response ${getPrintableError(endError)}` - ); - } -} diff --git a/ts/updater/windows.ts b/ts/updater/windows.ts index b9f403a10..9b0aac477 100644 --- a/ts/updater/windows.ts +++ b/ts/updater/windows.ts @@ -1,172 +1,60 @@ // Copyright 2019-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { dirname, join } from 'path'; +import { join } from 'path'; import type { SpawnOptions } from 'child_process'; import { spawn as spawnEmitter } from 'child_process'; import { readdir as readdirCallback, unlink as unlinkCallback } from 'fs'; -import type { BrowserWindow } from 'electron'; import { app } from 'electron'; -import config from 'config'; -import { gt } from 'semver'; import pify from 'pify'; -import type { UpdaterInterface } from './common'; -import { - checkForUpdates, - deleteTempDir, - downloadUpdate, - getAutoDownloadUpdateSetting, - getPrintableError, - setUpdateListener, -} from './common'; -import * as durations from '../util/durations'; -import type { LoggerType } from '../types/Logging'; -import { hexToBinary, verifySignature } from './signature'; +import { Updater } from './common'; import { markShouldQuit } from '../../app/window_state'; import { DialogType } from '../types/Dialogs'; const readdir = pify(readdirCallback); const unlink = pify(unlinkCallback); -const INTERVAL = 30 * durations.MINUTE; +const IS_EXE = /\.exe$/i; -let fileName: string; -let version: string; -let updateFilePath: string; -let installing: boolean; -let loggerForQuitHandler: LoggerType; +export class WindowsUpdater extends Updater { + private installing = false; -export async function start( - getMainWindow: () => BrowserWindow | undefined, - logger: LoggerType -): Promise { - logger.info('windows/start: starting checks...'); + // This is fixed by our new install mechanisms... + // https://github.com/signalapp/Signal-Desktop/issues/2369 + // ...but we should also clean up those old installers. + protected async deletePreviousInstallers(): Promise { + const userDataPath = app.getPath('userData'); + const files: Array = await readdir(userDataPath); + await Promise.all( + files.map(async file => { + const isExe = IS_EXE.test(file); + if (!isExe) { + return; + } - loggerForQuitHandler = logger; - app.once('quit', quitHandler); - - setInterval(async () => { - try { - await checkForUpdatesMaybeInstall(getMainWindow, logger); - } catch (error) { - logger.error(`windows/start: ${getPrintableError(error)}`); - } - }, INTERVAL); - - await deletePreviousInstallers(logger); - await checkForUpdatesMaybeInstall(getMainWindow, logger); - - return { - async force(): Promise { - return checkForUpdatesMaybeInstall(getMainWindow, logger, true); - }, - }; -} - -async function checkForUpdatesMaybeInstall( - getMainWindow: () => BrowserWindow | undefined, - logger: LoggerType, - force = false -) { - logger.info('checkForUpdatesMaybeInstall: checking for update...'); - const result = await checkForUpdates(logger, force); - if (!result) { - return; - } - - const { fileName: newFileName, version: newVersion } = result; - - if ( - force || - fileName !== newFileName || - !version || - gt(newVersion, version) - ) { - const autoDownloadUpdates = await getAutoDownloadUpdateSetting( - getMainWindow(), - logger + const fullPath = join(userDataPath, file); + try { + await unlink(fullPath); + } catch (error) { + this.logger.error( + `deletePreviousInstallers: couldn't delete file ${file}` + ); + } + }) ); - if (!autoDownloadUpdates) { - setUpdateListener(async () => { - logger.info( - 'checkForUpdatesMaybeInstall: have not downloaded update, going to download' - ); - await downloadAndInstall( - newFileName, - newVersion, - getMainWindow, - logger, - true - ); - }); - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send( - 'show-update-dialog', - DialogType.DownloadReady, - { - downloadSize: result.size, - version: result.version, - } - ); - } else { - logger.warn( - 'checkForUpdatesMaybeInstall: No mainWindow, not showing update dialog' - ); - } - return; - } - await downloadAndInstall(newFileName, newVersion, getMainWindow, logger); } -} - -async function downloadAndInstall( - newFileName: string, - newVersion: string, - getMainWindow: () => BrowserWindow | undefined, - logger: LoggerType, - updateOnProgress?: boolean -) { - try { - const oldFileName = fileName; - const oldVersion = version; - - deleteCache(updateFilePath, logger); - fileName = newFileName; - version = newVersion; - - try { - updateFilePath = await downloadUpdate( - fileName, - logger, - updateOnProgress ? getMainWindow() : undefined - ); - } catch (error) { - // Restore state in case of download error - fileName = oldFileName; - version = oldVersion; - throw error; - } - - const publicKey = hexToBinary(config.get('updatesPublicKey')); - const verified = await verifySignature(updateFilePath, version, publicKey); - if (!verified) { - // Note: We don't delete the cache here, because we don't want to continually - // re-download the broken release. We will download it only once per launch. - throw new Error( - `Downloaded update did not pass signature verification (version: '${version}'; fileName: '${fileName}')` - ); - } + protected async installUpdate(updateFilePath: string): Promise { + const { logger } = this; logger.info('downloadAndInstall: showing dialog...'); - setUpdateListener(async () => { + this.setUpdateListener(async () => { try { - await verifyAndInstall(updateFilePath, newVersion, logger); - installing = true; + await this.install(updateFilePath); + this.installing = true; } catch (error) { - const mainWindow = getMainWindow(); + const mainWindow = this.getMainWindow(); if (mainWindow) { logger.info( 'createUpdater: showing general update failure dialog...' @@ -185,110 +73,41 @@ async function downloadAndInstall( markShouldQuit(); app.quit(); }); - - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send('show-update-dialog', DialogType.Update, { - version, - }); - } else { - logger.warn( - 'downloadAndInstall: no mainWindow, cannot show update dialog' - ); - } - } catch (error) { - logger.error(`downloadAndInstall: ${getPrintableError(error)}`); } -} -function quitHandler() { - if (updateFilePath && !installing) { - verifyAndInstall(updateFilePath, version, loggerForQuitHandler).catch( - error => { - loggerForQuitHandler.error(`quitHandler: ${getPrintableError(error)}`); + private async install(filePath: string): Promise { + if (this.installing) { + return; + } + + const { logger } = this; + + logger.info('windows/install: installing package...'); + const args = ['--updated']; + const options = { + detached: true, + stdio: 'ignore' as const, // TypeScript considers this a plain string without help + }; + + try { + await spawn(filePath, args, options); + } catch (error) { + if (error.code === 'UNKNOWN' || error.code === 'EACCES') { + logger.warn( + 'windows/install: Error running installer; Trying again with elevate.exe' + ); + await spawn(getElevatePath(), [filePath, ...args], options); + + return; } - ); + + throw error; + } } } // Helpers -// This is fixed by out new install mechanisms... -// https://github.com/signalapp/Signal-Desktop/issues/2369 -// ...but we should also clean up those old installers. -const IS_EXE = /\.exe$/i; -async function deletePreviousInstallers(logger: LoggerType) { - const userDataPath = app.getPath('userData'); - const files: Array = await readdir(userDataPath); - await Promise.all( - files.map(async file => { - const isExe = IS_EXE.test(file); - if (!isExe) { - return; - } - - const fullPath = join(userDataPath, file); - try { - await unlink(fullPath); - } catch (error) { - logger.error(`deletePreviousInstallers: couldn't delete file ${file}`); - } - }) - ); -} - -async function verifyAndInstall( - filePath: string, - newVersion: string, - logger: LoggerType -) { - if (installing) { - return; - } - - const publicKey = hexToBinary(config.get('updatesPublicKey')); - const verified = await verifySignature(updateFilePath, newVersion, publicKey); - if (!verified) { - throw new Error( - `Downloaded update did not pass signature verification (version: '${newVersion}'; fileName: '${fileName}')` - ); - } - - await install(filePath, logger); -} - -async function install(filePath: string, logger: LoggerType): Promise { - logger.info('windows/install: installing package...'); - const args = ['--updated']; - const options = { - detached: true, - stdio: 'ignore' as const, // TypeScript considers this a plain string without help - }; - - try { - await spawn(filePath, args, options); - } catch (error) { - if (error.code === 'UNKNOWN' || error.code === 'EACCES') { - logger.warn( - 'windows/install: Error running installer; Trying again with elevate.exe' - ); - await spawn(getElevatePath(), [filePath, ...args], options); - - return; - } - - throw error; - } -} - -function deleteCache(filePath: string | null, logger: LoggerType) { - if (filePath) { - const tempDir = dirname(filePath); - deleteTempDir(tempDir).catch(error => { - logger.error(`deleteCache: ${getPrintableError(error)}`); - }); - } -} function getElevatePath() { const installPath = app.getAppPath(); diff --git a/ts/util/preload.ts b/ts/util/preload.ts index f7d44ac8c..82b836705 100644 --- a/ts/util/preload.ts +++ b/ts/util/preload.ts @@ -54,30 +54,12 @@ export function createSetting< function getValue(): Promise { strictAssert(options.getter, `${name} has no getter`); - return new Promise((resolve, reject) => { - ipcRenderer.once(`settings:get-success:${name}`, (_, error, value) => { - if (error) { - return reject(error); - } - - return resolve(value); - }); - ipcRenderer.send(`settings:get:${name}`); - }); + return ipcRenderer.invoke(`settings:get:${name}`); } function setValue(value: Value): Promise { strictAssert(options.setter, `${name} has no setter`); - return new Promise((resolve, reject) => { - ipcRenderer.once(`settings:set-success:${name}`, (_, error) => { - if (error) { - return reject(error); - } - - return resolve(value); - }); - ipcRenderer.send(`settings:set:${name}`, value); - }); + return ipcRenderer.invoke(`settings:set:${name}`, value); } return { @@ -98,16 +80,7 @@ export function createCallback< name: Name ): (...args: Parameters) => Promise> { return (...args: Parameters): Promise> => { - return new Promise>((resolve, reject) => { - ipcRenderer.once(`callbacks:call-success:${name}`, (_, error, value) => { - if (error) { - return reject(error); - } - - return resolve(value); - }); - ipcRenderer.send(`callbacks:call:${name}`, args); - }); + return ipcRenderer.invoke(`settings:call:${name}`, args); }; } @@ -119,7 +92,7 @@ export function installSetting( const setterName = getSetterName(name); if (getter) { - ipcRenderer.on(`settings:get:${name}`, async () => { + ipcRenderer.on(`settings:get:${name}`, async (_event, { seq }) => { const getFn = window.Events[getterName]; if (!getFn) { ipcRenderer.send( @@ -129,10 +102,11 @@ export function installSetting( return; } try { - ipcRenderer.send(`settings:get-success:${name}`, null, await getFn()); + ipcRenderer.send('settings:response', seq, null, await getFn()); } catch (error) { ipcRenderer.send( - `settings:get-success:${name}`, + 'settings:response', + seq, error && error.stack ? error.stack : error ); } @@ -140,7 +114,7 @@ export function installSetting( } if (setter) { - ipcRenderer.on(`settings:set:${name}`, async (_event, value: unknown) => { + ipcRenderer.on(`settings:set:${name}`, async (_event, { seq, value }) => { // Some settings do not have setters... // eslint-disable-next-line @typescript-eslint/no-explicit-any const setFn = (window.Events as any)[setterName] as ( @@ -148,17 +122,19 @@ export function installSetting( ) => Promise; if (!setFn) { ipcRenderer.send( - `settings:set-success:${name}`, + 'settings:response', + seq, `installSetter: ${setterName} not found for event ${name}` ); return; } try { await setFn(value); - ipcRenderer.send(`settings:set-success:${name}`); + ipcRenderer.send('settings:response', seq, null); } catch (error) { ipcRenderer.send( - `settings:set-success:${name}`, + 'settings:response', + seq, error && error.stack ? error.stack : error ); } @@ -169,19 +145,16 @@ export function installSetting( export function installCallback( name: Name ): void { - ipcRenderer.on(`callbacks:call:${name}`, async (_, args) => { + ipcRenderer.on(`settings:call:${name}`, async (_, { seq, args }) => { const hook = window.Events[name] as ( ...hookArgs: Array ) => Promise; try { - ipcRenderer.send( - `callbacks:call-success:${name}`, - null, - await hook(...args) - ); + ipcRenderer.send('settings:response', seq, null, await hook(...args)); } catch (error) { ipcRenderer.send( - `callbacks:call-success:${name}`, + 'settings:response', + seq, error && error.stack ? error.stack : error ); } diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index 8704c8497..cf31d395b 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -67,32 +67,7 @@ installSetting('preferredAudioInputDevice'); installSetting('preferredAudioOutputDevice'); installSetting('preferredVideoInputDevice'); -window.getMediaPermissions = () => - new Promise((resolve, reject) => { - ipc.once( - 'settings:get-success:mediaPermissions', - (_event, error, value) => { - if (error) { - return reject(new Error(error)); - } - - return resolve(value); - } - ); - ipc.send('settings:get:mediaPermissions'); - }); +window.getMediaPermissions = () => ipc.invoke('settings:get:mediaPermissions'); window.getMediaCameraPermissions = () => - new Promise((resolve, reject) => { - ipc.once( - 'settings:get-success:mediaCameraPermissions', - (_event, error, value) => { - if (error) { - return reject(new Error(error)); - } - - return resolve(value); - } - ); - ipc.send('settings:get:mediaCameraPermissions'); - }); + ipc.invoke('settings:get:mediaCameraPermissions');