// Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { BrowserWindow } from 'electron'; import { ipcMain as ipc, session } from 'electron'; import { EventEmitter } from 'events'; 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, } from '../util/createIPCEvents'; import type { EphemeralSettings, SettingsValuesType } from '../util/preload'; const EPHEMERAL_NAME_MAP = new Map([ ['spellCheck', 'spell-check'], ['systemTraySetting', 'system-tray-setting'], ['themeSetting', 'theme-setting'], ['localeOverride', 'localeOverride'], ]); type ResponseQueueEntry = Readonly<{ resolve(value: unknown): void; reject(error: Error): void; }>; type SettingChangeEventType = `change:${Key}`; export class SettingsChannel extends EventEmitter { 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 }); this.installSetting('phoneNumber', { setter: false }); // ChatColorPicker redux hookups this.installCallback('getCustomColors'); this.installCallback('getConversationsWithCustomColor'); this.installCallback('resetAllChatColors'); this.installCallback('resetDefaultChatColor'); this.installCallback('addCustomColor'); this.installCallback('editCustomColor'); this.installCallback('removeCustomColor'); this.installCallback('removeCustomColorOnConversations'); this.installCallback('setGlobalDefaultConversationColor'); this.installCallback('getDefaultConversationColor'); // Various callbacks this.installCallback('deleteAllMyStories'); this.installCallback('getAvailableIODevices'); this.installCallback('isPrimary'); this.installCallback('syncRequest'); // Getters only. These are set by the primary device this.installSetting('blockedCount', { setter: false }); this.installSetting('linkPreviewSetting', { setter: false }); this.installSetting('readReceiptSetting', { setter: false }); this.installSetting('typingIndicatorSetting', { setter: false }); this.installSetting('hideMenuBar'); this.installSetting('notificationSetting'); this.installSetting('notificationDrawAttention'); this.installSetting('audioMessage'); this.installSetting('audioNotification'); this.installSetting('countMutedConversations'); this.installSetting('sentMediaQualitySetting'); this.installSetting('textFormatting'); this.installSetting('autoConvertEmoji'); this.installSetting('autoDownloadUpdate'); this.installSetting('autoLaunch'); this.installSetting('alwaysRelayCalls'); this.installSetting('callRingtoneNotification'); this.installSetting('callSystemNotification'); this.installSetting('incomingCallNotification'); // Media settings this.installSetting('preferredAudioInputDevice'); this.installSetting('preferredAudioOutputDevice'); this.installSetting('preferredVideoInputDevice'); this.installSetting('lastSyncTime'); this.installSetting('universalExpireTimer'); this.installSetting('hasStoriesDisabled'); this.installSetting('zoomFactor'); this.installSetting('phoneNumberDiscoverabilitySetting'); this.installSetting('phoneNumberSharingSetting'); this.installEphemeralSetting('themeSetting'); this.installEphemeralSetting('systemTraySetting'); this.installEphemeralSetting('localeOverride'); this.installEphemeralSetting('spellCheck'); installPermissionsHandler({ session: session.defaultSession, userConfig }); // These ones are different because its single source of truth is userConfig, // not IndexedDB ipc.handle('settings:get:mediaPermissions', () => { return userConfig.get('mediaPermissions') || false; }); ipc.handle('settings:get:mediaCameraPermissions', () => { return userConfig.get('mediaCameraPermissions') || false; }); 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: session.defaultSession, userConfig, }); }); 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: session.defaultSession, userConfig, }); }); 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; 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.handle(`settings:call:${name}`, async (_event, args) => { return this.invokeCallbackInMainWindow(name, args); }); } private installSetting( name: Name, { getter = true, setter = true, }: { getter?: boolean; setter?: boolean } = {} ): void { if (getter) { ipc.handle(`settings:get:${name}`, async () => { return this.getSettingFromMainWindow(name); }); } if (!setter) { return; } ipc.handle(`settings:set:${name}`, async (_event, value) => { await this.setSettingInMainWindow(name, value); this.emit(`change:${name}`, value); }); } private installEphemeralSetting( name: Name ): void { ipc.handle(`settings:get:${name}`, async () => { const ephemeralName = EPHEMERAL_NAME_MAP.get(name); strictAssert( ephemeralName !== undefined, `${name} is not an ephemeral setting` ); return ephemeralConfig.get(ephemeralName); }); ipc.handle(`settings:set:${name}`, async (_event, value) => { const ephemeralName = EPHEMERAL_NAME_MAP.get(name); strictAssert( ephemeralName !== undefined, `${name} is not an ephemeral setting` ); ephemeralConfig.set(ephemeralName, value); this.emit(`change:${name}`, value); // Notify main to notify windows of preferences change. As for DB-backed // settings, those are set by the renderer, and afterwards the renderer IPC sends // to main the event 'preferences-changed'. this.emit('ephemeral-setting-changed'); const { mainWindow } = this; if (!mainWindow || !mainWindow.webContents) { return; } mainWindow.webContents.send(`settings:update:${name}`, value); }); } // EventEmitter types public override on( type: 'change:systemTraySetting', callback: (value: string) => void ): this; public override on( type: 'ephemeral-setting-changed', callback: () => void ): this; public override on( type: SettingChangeEventType, // eslint-disable-next-line @typescript-eslint/no-explicit-any callback: (...args: Array) => void ): this; public override on( type: string | symbol, // eslint-disable-next-line @typescript-eslint/no-explicit-any listener: (...args: Array) => void ): this { return super.on(type, listener); } public override emit( type: 'change:systemTraySetting', value: string ): boolean; public override emit(type: 'ephemeral-setting-changed'): boolean; public override emit( type: SettingChangeEventType, // eslint-disable-next-line @typescript-eslint/no-explicit-any ...args: Array ): boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any public override emit(type: string | symbol, ...args: Array): boolean { return super.emit(type, ...args); } }