// Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { join, normalize, extname, dirname, basename } from 'path'; import { pathToFileURL } from 'url'; import * as os from 'os'; import { chmod, realpath, writeFile } from 'fs-extra'; import { randomBytes } from 'crypto'; import { createParser } from 'dashdash'; import normalizePath from 'normalize-path'; import fastGlob from 'fast-glob'; import PQueue from 'p-queue'; import { get, pick, isNumber, isBoolean, some, debounce, noop } from 'lodash'; import { app, BrowserWindow, clipboard, dialog, ipcMain as ipc, Menu, nativeTheme, net, powerSaveBlocker, screen, session, shell, systemPreferences, Notification, safeStorage, protocol as electronProtocol, } from 'electron'; import type { MenuItemConstructorOptions, Settings } from 'electron'; import { z } from 'zod'; import packageJson from '../package.json'; import * as GlobalErrors from './global_errors'; import { setup as setupCrashReports } from './crashReports'; import { setup as setupSpellChecker } from './spell_check'; import { getDNSFallback } from './dns-fallback'; import { redactAll, addSensitivePath } from '../ts/util/privacy'; import { createSupportUrl } from '../ts/util/createSupportUrl'; import { missingCaseError } from '../ts/util/missingCaseError'; import { strictAssert } from '../ts/util/assert'; import { drop } from '../ts/util/drop'; import { createBufferedConsoleLogger } from '../ts/util/consoleLogger'; import type { ThemeSettingType } from '../ts/types/StorageUIKeys'; import { ThemeType } from '../ts/types/Util'; import * as Errors from '../ts/types/errors'; import { resolveCanonicalLocales } from '../ts/util/resolveCanonicalLocales'; import * as debugLog from '../ts/logging/debuglogs'; import * as uploadDebugLog from '../ts/logging/uploadDebugLog'; import { explodePromise } from '../ts/util/explodePromise'; import './startup_config'; import type { RendererConfigType } from '../ts/types/RendererConfig'; import { directoryConfigSchema, rendererConfigSchema, } from '../ts/types/RendererConfig'; import config from './config'; import { Environment, getEnvironment, isTestEnvironment, } from '../ts/environment'; // Very important to put before the single instance check, since it is based on the // userData directory. (see requestSingleInstanceLock below) import * as userConfig from './user_config'; // We generally want to pull in our own modules after this point, after the user // data directory has been set. import * as attachments from './attachments'; import * as attachmentChannel from './attachment_channel'; import * as bounce from '../ts/services/bounce'; import * as updater from '../ts/updater/index'; import { updateDefaultSession } from './updateDefaultSession'; import { PreventDisplaySleepService } from './PreventDisplaySleepService'; import { SystemTrayService, focusAndForceToTop } from './SystemTrayService'; import { SystemTraySettingCache } from './SystemTraySettingCache'; import { OptionalResourceService } from './OptionalResourceService'; import { EmojiService } from './EmojiService'; import { SystemTraySetting, shouldMinimizeToSystemTray, parseSystemTraySetting, } from '../ts/types/SystemTraySetting'; import { getDefaultSystemTraySetting, isSystemTraySupported, } from '../ts/types/Settings'; import * as ephemeralConfig from './ephemeral_config'; import * as logging from '../ts/logging/main_process_logging'; import { MainSQL } from '../ts/sql/main'; import * as sqlChannels from './sql_channel'; import * as windowState from './window_state'; import type { CreateTemplateOptionsType } from './menu'; import { createTemplate } from './menu'; import { installFileHandler, installWebHandler } from './protocol_filter'; import OS from '../ts/util/os/osMain'; import { isProduction } from '../ts/util/version'; import { clearTimeoutIfNecessary } from '../ts/util/clearTimeoutIfNecessary'; import { toggleMaximizedBrowserWindow } from '../ts/util/toggleMaximizedBrowserWindow'; import { ChallengeMainHandler } from '../ts/main/challengeMain'; import { NativeThemeNotifier } from '../ts/main/NativeThemeNotifier'; import { PowerChannel } from '../ts/main/powerChannel'; import { SettingsChannel } from '../ts/main/settingsChannel'; import { maybeParseUrl, setUrlSearchParams } from '../ts/util/url'; import { getHeicConverter } from '../ts/workers/heicConverterMain'; import type { LocaleDirection, LocaleType } from './locale'; import { load as loadLocale } from './locale'; import type { LoggerType } from '../ts/types/Logging'; import { HourCyclePreference } from '../ts/types/I18N'; import { ScreenShareStatus } from '../ts/types/Calling'; import type { ParsedSignalRoute } from '../ts/util/signalRoutes'; import { parseSignalRoute } from '../ts/util/signalRoutes'; import * as dns from '../ts/util/dns'; import { ZoomFactorService } from '../ts/services/ZoomFactorService'; import { SafeStorageBackendChangeError } from '../ts/types/SafeStorageBackendChangeError'; import { LINUX_PASSWORD_STORE_FLAGS } from '../ts/util/linuxPasswordStoreFlags'; import { getOwn } from '../ts/util/getOwn'; import { safeParseLoose, safeParseUnknown } from '../ts/util/schemas'; const animationSettings = systemPreferences.getAnimationSettings(); if (OS.isMacOS()) { systemPreferences.setUserDefault( 'SquirrelMacEnableDirectContentsWrite', 'boolean', true ); } // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. let mainWindow: BrowserWindow | undefined; let mainWindowCreated = false; let loadingWindow: BrowserWindow | undefined; // Create a buffered logger to hold our log lines until we fully initialize // the logger in `app.on('ready')` const consoleLogger = createBufferedConsoleLogger(); // These will be set after app fires the 'ready' event let logger: LoggerType | undefined; let preferredSystemLocales: Array | undefined; let localeOverride: string | null | undefined; let resolvedTranslationsLocale: LocaleType | undefined; let settingsChannel: SettingsChannel | undefined; const activeWindows = new Set(); function getMainWindow() { return mainWindow; } const development = getEnvironment() === Environment.Development || getEnvironment() === Environment.Staging; const ciMode = config.get<'full' | 'benchmark' | false>('ciMode'); const forcePreloadBundle = config.get('forcePreloadBundle'); const localeDirectionTestingOverride = config.has( 'localeDirectionTestingOverride' ) ? config.get('localeDirectionTestingOverride') : null; const preventDisplaySleepService = new PreventDisplaySleepService( powerSaveBlocker ); const challengeHandler = new ChallengeMainHandler(); const nativeThemeNotifier = new NativeThemeNotifier(); nativeThemeNotifier.initialize(); let appStartInitialSpellcheckSetting = true; let macInitialOpenUrlRoute: ParsedSignalRoute | undefined; const cliParser = createParser({ allowUnknown: true, options: [ { name: 'lang', type: 'string', }, ], }); const cliOptions = cliParser.parse(process.argv); const defaultWebPrefs = { devTools: process.argv.some(arg => arg === '--enable-dev-tools') || getEnvironment() !== Environment.PackagedApp || !isProduction(app.getVersion()), spellcheck: false, // https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/runtime_enabled_features.json5 enableBlinkFeatures: [ 'CSSPseudoDir', // status=experimental, needed for RTL (ex: :dir(rtl)) 'CSSLogical', // status=experimental, needed for RTL (ex: margin-inline-start) ].join(','), enablePreferredSizeMode: true, }; const DISABLE_GPU = OS.isLinux() && !process.argv.some(arg => arg === '--enable-gpu'); const DISABLE_IPV6 = process.argv.some(arg => arg === '--disable-ipv6'); const FORCE_ENABLE_CRASH_REPORTS = process.argv.some( arg => arg === '--enable-crash-reports' ); const CLI_LANG = cliOptions.lang as string | undefined; setupCrashReports(getLogger, showDebugLogWindow, FORCE_ENABLE_CRASH_REPORTS); let sendDummyKeystroke: undefined | (() => void); if (OS.isWindows()) { try { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires const windowsNotifications = require('./WindowsNotifications'); sendDummyKeystroke = windowsNotifications.sendDummyKeystroke; } catch (error) { getLogger().error( 'Failed to initialize Windows Notifications:', error.stack ); } } function showWindow() { if (!mainWindow) { return; } // Using focus() instead of show() seems to be important on Windows when our window // has been docked using Aero Snap/Snap Assist. A full .show() call here will cause // the window to reposition: // https://github.com/signalapp/Signal-Desktop/issues/1429 if (mainWindow.isVisible()) { focusAndForceToTop(mainWindow); } else { mainWindow.show(); } } if (!process.mas) { console.log('making app single instance'); const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { console.log('quitting; we are the second instance'); app.exit(); } else { app.on('second-instance', (_e: Electron.Event, argv: Array) => { // Workaround to let AllowSetForegroundWindow succeed. // See https://www.npmjs.com/package/@signalapp/windows-dummy-keystroke for a full explanation of why this is needed. sendDummyKeystroke?.(); // Someone tried to run a second instance, we should focus our window if (mainWindow) { if (mainWindow.isMinimized()) { mainWindow.restore(); } showWindow(); } if (!logger) { console.log( 'second-instance: logger not initialized; skipping further checks' ); return; } const route = maybeGetIncomingSignalRoute(argv); if (route != null) { handleSignalRoute(route); } return true; }); // This event is received in macOS packaged builds. app.on('open-url', (event, incomingHref) => { event.preventDefault(); const route = parseSignalRoute(incomingHref); if (route != null) { // When the app isn't open and you click a signal link to open the app, then // this event will emit before mainWindow is ready. We save the value for later. if (mainWindow == null || !mainWindow.webContents) { macInitialOpenUrlRoute = route; return; } handleSignalRoute(route); } }); } } let sqlInitTimeStart = 0; let sqlInitTimeEnd = 0; const sql = new MainSQL(); const heicConverter = getHeicConverter(); async function getSpellCheckSetting(): Promise { const value = ephemeralConfig.get('spell-check'); if (typeof value === 'boolean') { getLogger().info('got fast spellcheck setting', value); return value; } // Default to `true` if setting doesn't exist yet ephemeralConfig.set('spell-check', true); getLogger().info('initializing spellcheck setting', true); return true; } type GetThemeSettingOptionsType = Readonly<{ ephemeralOnly?: boolean; }>; async function getThemeSetting({ ephemeralOnly = false, }: GetThemeSettingOptionsType = {}): Promise { const value = ephemeralConfig.get('theme-setting'); if (value !== undefined) { getLogger().info('got fast theme-setting value', value); } else if (ephemeralOnly) { return 'system'; } // Default to `system` if setting doesn't exist or is invalid const validatedResult = value === 'light' || value === 'dark' || value === 'system' ? value : 'system'; if (value !== validatedResult) { ephemeralConfig.set('theme-setting', validatedResult); getLogger().info('saving theme-setting value', validatedResult); } return validatedResult; } async function getResolvedThemeSetting( options?: GetThemeSettingOptionsType ): Promise { const theme = await getThemeSetting(options); if (theme === 'system') { return nativeTheme.shouldUseDarkColors ? ThemeType.dark : ThemeType.light; } return ThemeType[theme]; } type GetBackgroundColorOptionsType = GetThemeSettingOptionsType & Readonly<{ signalColors?: boolean; }>; async function getBackgroundColor( options?: GetBackgroundColorOptionsType ): Promise { const theme = await getResolvedThemeSetting(options); if (theme === 'light') { return options?.signalColors ? '#3a76f0' : '#ffffff'; } if (theme === 'dark') { return '#121212'; } throw missingCaseError(theme); } async function getLocaleOverrideSetting(): Promise { const value = ephemeralConfig.get('localeOverride'); // eslint-disable-next-line eqeqeq -- Checking for null explicitly if (typeof value === 'string' || value === null) { getLogger().info('got fast localeOverride setting', value); return value; } // Default to `null` if setting doesn't exist yet ephemeralConfig.set('localeOverride', null); getLogger().info('initializing localeOverride setting', null); return null; } const zoomFactorService = new ZoomFactorService({ async getZoomFactorSetting() { const item = await sql.sqlRead('getItemById', 'zoomFactor'); if (typeof item?.value !== 'number') { return null; } return item.value; }, async setZoomFactorSetting(zoomFactor) { await sql.sqlWrite('createOrUpdateItem', { id: 'zoomFactor', value: zoomFactor, }); }, }); let systemTrayService: SystemTrayService | undefined; const systemTraySettingCache = new SystemTraySettingCache( ephemeralConfig, process.argv ); const windowFromUserConfig = userConfig.get('window'); const windowFromEphemeral = ephemeralConfig.get('window'); export const windowConfigSchema = z.object({ maximized: z.boolean().optional(), autoHideMenuBar: z.boolean().optional(), fullscreen: z.boolean().optional(), width: z.number(), height: z.number(), x: z.number(), y: z.number(), }); type WindowConfigType = z.infer; let windowConfig: WindowConfigType | undefined; const windowConfigParsed = safeParseUnknown( windowConfigSchema, windowFromEphemeral || windowFromUserConfig ); if (windowConfigParsed.success) { windowConfig = windowConfigParsed.data; } if (windowFromUserConfig) { userConfig.set('window', null); ephemeralConfig.set('window', windowConfig); } let menuOptions: CreateTemplateOptionsType | undefined; function getLogger(): LoggerType { if (!logger) { console.warn('getLogger: Logger not yet initialized!'); return consoleLogger; } return logger; } function getPreferredSystemLocales(): Array { if (!preferredSystemLocales) { throw new Error('getPreferredSystemLocales: Locales not yet initialized!'); } return preferredSystemLocales; } function getLocaleOverride(): string | null { if (typeof localeOverride === 'undefined') { throw new Error('getLocaleOverride: Locale not yet initialized!'); } return localeOverride; } function getResolvedMessagesLocale(): LocaleType { if (!resolvedTranslationsLocale) { throw new Error('getResolvedMessagesLocale: Locale not yet initialized!'); } return resolvedTranslationsLocale; } function getHourCyclePreference(): HourCyclePreference { if (process.platform !== 'darwin') { return HourCyclePreference.UnknownPreference; } if (systemPreferences.getUserDefault('AppleICUForce24HourTime', 'boolean')) { return HourCyclePreference.Prefer24; } if (systemPreferences.getUserDefault('AppleICUForce12HourTime', 'boolean')) { return HourCyclePreference.Prefer12; } return HourCyclePreference.UnknownPreference; } type PrepareUrlOptions = { forCalling?: boolean; forCamera?: boolean; sourceName?: string; }; async function prepareFileUrl( pathSegments: ReadonlyArray, options: PrepareUrlOptions = {} ): Promise { const filePath = join(...pathSegments); const fileUrl = pathToFileURL(filePath) as URL; return prepareUrl(fileUrl, options); } async function prepareUrl( url: URL, { forCalling, forCamera, sourceName }: PrepareUrlOptions = {} ): Promise { return setUrlSearchParams(url, { forCalling, forCamera, sourceName }).href; } async function handleUrl(rawTarget: string) { const parsedUrl = maybeParseUrl(rawTarget); if (!parsedUrl) { return; } const signalRoute = parseSignalRoute(rawTarget); // We only want to specially handle urls that aren't requesting the dev server if (signalRoute != null) { handleSignalRoute(signalRoute); return; } const { protocol, hostname } = parsedUrl; const isDevServer = process.env.SIGNAL_ENABLE_HTTP && hostname === 'localhost'; if ((protocol === 'http:' || protocol === 'https:') && !isDevServer) { try { await shell.openExternal(rawTarget); } catch (error) { getLogger().error(`Failed to open url: ${Errors.toLogFormat(error)}`); } } } async function handleCommonWindowEvents(window: BrowserWindow) { window.webContents.on('will-navigate', (event, rawTarget) => { event.preventDefault(); drop(handleUrl(rawTarget)); }); window.webContents.setWindowOpenHandler(({ url }) => { drop(handleUrl(url)); return { action: 'deny' }; }); window.webContents.on( 'preload-error', (_event: Electron.Event, preloadPath: string, error: Error) => { getLogger().error(`Preload error in ${preloadPath}: `, error.message); } ); activeWindows.add(window); window.on('closed', () => activeWindows.delete(window)); const setWindowFocus = () => { window.webContents.send('set-window-focus', window.isFocused()); }; window.on('focus', setWindowFocus); window.on('blur', setWindowFocus); window.once('ready-to-show', setWindowFocus); // This is a fallback in case we drop an event for some reason. const focusInterval = setInterval(setWindowFocus, 10000); window.on('closed', () => clearInterval(focusInterval)); await zoomFactorService.syncWindow(window); nativeThemeNotifier.addWindow(window); } const DEFAULT_WIDTH = ciMode ? 1024 : 800; const DEFAULT_HEIGHT = ciMode ? 1024 : 610; // We allow for smaller sizes because folks with OS-level zoom and HighDPI/Large Text // can really cause weirdness around window pixel-sizes. The app is very broken if you // make the window this small and do nothing else. But if you zoom out and collapse the // left pane, even this window size can work! const MIN_WIDTH = 300; const MIN_HEIGHT = 200; const BOUNDS_BUFFER = 100; type BoundsType = { width: number; height: number; x: number; y: number; }; function isVisible(window: BoundsType, bounds: BoundsType) { const boundsX = bounds?.x || 0; const boundsY = bounds?.y || 0; const boundsWidth = bounds?.width || DEFAULT_WIDTH; const boundsHeight = bounds?.height || DEFAULT_HEIGHT; // requiring BOUNDS_BUFFER pixels on the left or right side const rightSideClearOfLeftBound = window.x + window.width >= boundsX + BOUNDS_BUFFER; const leftSideClearOfRightBound = window.x <= boundsX + boundsWidth - BOUNDS_BUFFER; // top can't be offscreen, and must show at least BOUNDS_BUFFER pixels at bottom const topClearOfUpperBound = window.y >= boundsY; const topClearOfLowerBound = window.y <= boundsY + boundsHeight - BOUNDS_BUFFER; return ( rightSideClearOfLeftBound && leftSideClearOfRightBound && topClearOfUpperBound && topClearOfLowerBound ); } let windowIcon: string; if (OS.isWindows()) { windowIcon = join(__dirname, '../build/icons/win/icon.ico'); } else if (OS.isLinux()) { windowIcon = join(__dirname, '../images/signal-logo-desktop-linux.png'); } else { windowIcon = join(__dirname, '../build/icons/png/512x512.png'); } // The titlebar is hidden on: // - Windows < 10 (7, 8) // - macOS (but no custom titlebar is displayed, see // `--title-bar-drag-area-height` in `stylesheets/_titlebar.scss` const mainTitleBarStyle = OS.isMacOS() ? ('hidden' as const) : ('default' as const); const nonMainTitleBarStyle = 'default' as const; async function safeLoadURL(window: BrowserWindow, url: string): Promise { let wasDestroyed = false; const onDestroyed = () => { wasDestroyed = true; }; window.webContents.on('did-stop-loading', onDestroyed); window.webContents.on('destroyed', onDestroyed); try { await window.loadURL(url); } catch (error) { if ( (wasDestroyed || windowState.readyForShutdown()) && error?.code === 'ERR_FAILED' ) { getLogger().warn( 'safeLoadURL: ignoring ERR_FAILED because we are shutting down', error ); return; } throw error; } finally { try { window.webContents.removeListener('did-stop-loading', onDestroyed); window.webContents.removeListener('destroyed', onDestroyed); } catch { // We already logged or thrown an error - don't bother with handling the // error here. } } } async function createWindow() { const usePreloadBundle = !isTestEnvironment(getEnvironment()) || forcePreloadBundle; const primaryDisplay = screen.getPrimaryDisplay(); const { width: maxWidth, height: maxHeight } = primaryDisplay.workAreaSize; const width = windowConfig ? Math.min(windowConfig.width, maxWidth) : DEFAULT_WIDTH; const height = windowConfig ? Math.min(windowConfig.height, maxHeight) : DEFAULT_HEIGHT; const windowOptions: Electron.BrowserWindowConstructorOptions = { show: false, width, height, minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT, autoHideMenuBar: false, titleBarStyle: mainTitleBarStyle, backgroundColor: isTestEnvironment(getEnvironment()) ? '#ffffff' // Tests should always be rendered on a white background : await getBackgroundColor({ signalColors: true }), webPreferences: { ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, sandbox: false, contextIsolation: !isTestEnvironment(getEnvironment()), preload: join( __dirname, usePreloadBundle ? '../preload.wrapper.js' : '../ts/windows/main/preload.js' ), spellcheck: await getSpellCheckSetting(), backgroundThrottling: true, disableBlinkFeatures: 'Accelerated2dCanvas,AcceleratedSmallCanvases', }, icon: windowIcon, ...pick(windowConfig, ['autoHideMenuBar', 'x', 'y']), }; if (!isNumber(windowOptions.width) || windowOptions.width < MIN_WIDTH) { windowOptions.width = DEFAULT_WIDTH; } if (!isNumber(windowOptions.height) || windowOptions.height < MIN_HEIGHT) { windowOptions.height = DEFAULT_HEIGHT; } if (!isBoolean(windowOptions.autoHideMenuBar)) { delete windowOptions.autoHideMenuBar; } const startInTray = isTestEnvironment(getEnvironment()) || (await systemTraySettingCache.get()) === SystemTraySetting.MinimizeToAndStartInSystemTray; const haveFullWindowsBounds = isNumber(windowOptions.x) && isNumber(windowOptions.y) && isNumber(windowOptions.width) && isNumber(windowOptions.height); if (haveFullWindowsBounds) { getLogger().info( `visibleOnAnyScreen(window): x=${windowOptions.x}, y=${windowOptions.y}, ` + `width=${windowOptions.width}, height=${windowOptions.height}` ); const visibleOnAnyScreen = some(screen.getAllDisplays(), display => { const displayBounds = get(display, 'bounds'); getLogger().info( `visibleOnAnyScreen(display #${display.id}): ` + `x=${displayBounds.x}, y=${displayBounds.y}, ` + `width=${displayBounds.width}, height=${displayBounds.height}` ); return isVisible(windowOptions as BoundsType, displayBounds); }); if (!visibleOnAnyScreen) { getLogger().info('visibleOnAnyScreen: Location reset needed'); delete windowOptions.x; delete windowOptions.y; } } getLogger().info( 'Initializing BrowserWindow config:', JSON.stringify(windowOptions) ); // Create the browser window. mainWindow = new BrowserWindow(windowOptions); if (settingsChannel) { settingsChannel.setMainWindow(mainWindow); } mainWindowCreated = true; setupSpellChecker( mainWindow, getPreferredSystemLocales(), getLocaleOverride(), getResolvedMessagesLocale().i18n, getLogger() ); if (!startInTray && windowConfig && windowConfig.maximized) { mainWindow.maximize(); } if (!startInTray && windowConfig && windowConfig.fullscreen) { mainWindow.setFullScreen(true); } if (systemTrayService) { systemTrayService.setMainWindow(mainWindow); } function saveWindowStats() { if (!windowConfig) { return; } getLogger().info( 'Updating BrowserWindow config: %s', JSON.stringify(windowConfig) ); ephemeralConfig.set('window', windowConfig); } const debouncedSaveStats = debounce(saveWindowStats, 500); function captureWindowStats() { if (!mainWindow) { return; } const size = mainWindow.getSize(); const position = mainWindow.getPosition(); const newWindowConfig = { maximized: mainWindow.isMaximized(), autoHideMenuBar: mainWindow.autoHideMenuBar, fullscreen: mainWindow.isFullScreen(), width: size[0], height: size[1], x: position[0], y: position[1], }; if ( newWindowConfig.fullscreen !== windowConfig?.fullscreen || newWindowConfig.maximized !== windowConfig?.maximized ) { mainWindow.webContents.send('window:set-window-stats', { isMaximized: newWindowConfig.maximized, isFullScreen: newWindowConfig.fullscreen, }); } // so if we need to recreate the window, we have the most recent settings windowConfig = newWindowConfig; if (!windowState.requestedShutdown()) { debouncedSaveStats(); } } mainWindow.on('resize', captureWindowStats); mainWindow.on('move', captureWindowStats); if (!ciMode && config.get('openDevTools')) { // Open the DevTools. mainWindow.webContents.openDevTools(); } await handleCommonWindowEvents(mainWindow); // App dock icon bounce bounce.init(mainWindow); // Emitted when the window is about to be closed. // Note: We do most of our shutdown logic here because all windows are closed by // Electron before the app quits. mainWindow.on('close', async e => { if (!mainWindow) { getLogger().info('close event: no main window'); return; } getLogger().info('close event', { readyForShutdown: windowState.readyForShutdown(), shouldQuit: windowState.shouldQuit(), }); // If the application is terminating, just do the default if ( isTestEnvironment(getEnvironment()) || (windowState.readyForShutdown() && windowState.shouldQuit()) ) { return; } // Prevent the shutdown e.preventDefault(); // Disable media playback mainWindow.webContents.send('set-media-playback-disabled', true); // In certain cases such as during an active call, we ask the user to confirm close // which includes shutdown, clicking X on MacOS or closing to tray. let shouldClose = true; try { shouldClose = await maybeRequestCloseConfirmation(); } catch (error) { getLogger().warn( 'Error while requesting close confirmation.', Errors.toLogFormat(error) ); } if (!shouldClose) { updater.onRestartCancelled(); return; } /** * if the user is in fullscreen mode and closes the window, not the * application, we need them leave fullscreen first before closing it to * prevent a black screen. * Also check for mainWindow because it might become undefined while * waiting for close confirmation. * * issue: https://github.com/signalapp/Signal-Desktop/issues/4348 */ if (mainWindow) { if (mainWindow.isFullScreen()) { mainWindow.once('leave-full-screen', () => mainWindow?.hide()); mainWindow.setFullScreen(false); } else { mainWindow.hide(); } } // On Mac, or on other platforms when the tray icon is in use, the window // should be only hidden, not closed, when the user clicks the close button const usingTrayIcon = shouldMinimizeToSystemTray( await systemTraySettingCache.get() ); if ( mainWindow && !windowState.shouldQuit() && (usingTrayIcon || OS.isMacOS()) ) { if (usingTrayIcon) { const shownTrayNotice = ephemeralConfig.get('shown-tray-notice'); if (shownTrayNotice) { getLogger().info('close: not showing tray notice'); return; } ephemeralConfig.set('shown-tray-notice', true); getLogger().info('close: showing tray notice'); const n = new Notification({ title: getResolvedMessagesLocale().i18n( 'icu:minimizeToTrayNotification--title' ), body: getResolvedMessagesLocale().i18n( 'icu:minimizeToTrayNotification--body' ), }); n.show(); } return; } windowState.markRequestedShutdown(); await requestShutdown(); windowState.markReadyForShutdown(); await sql.close(); app.quit(); }); // Emitted when the window is closed. mainWindow.on('closed', () => { // Dereference the window object, usually you would store windows // in an array if your app supports multi windows, this is the time // when you should delete the corresponding element. getLogger().info('main window closed event'); mainWindow = undefined; if (settingsChannel) { settingsChannel.setMainWindow(mainWindow); } if (systemTrayService) { systemTrayService.setMainWindow(mainWindow); } }); mainWindow.on('enter-full-screen', () => { getLogger().info('mainWindow enter-full-screen event'); if (mainWindow) { mainWindow.webContents.send('full-screen-change', true); } }); mainWindow.on('leave-full-screen', () => { getLogger().info('mainWindow leave-full-screen event'); if (mainWindow) { mainWindow.webContents.send('full-screen-change', false); } }); mainWindow.on('show', () => { if (mainWindow) { mainWindow.webContents.send('set-media-playback-disabled', false); } }); mainWindow.once('ready-to-show', async () => { getLogger().info('main window is ready-to-show'); // Ignore sql errors and show the window anyway await sqlInitPromise; if (!mainWindow) { return; } mainWindow.webContents.send('ci:event', 'db-initialized', {}); const shouldShowWindow = !app.getLoginItemSettings().wasOpenedAsHidden && !startInTray && !config.get('ciIsBackupIntegration'); if (shouldShowWindow) { getLogger().info('showing main window'); mainWindow.show(); } }); await safeLoadURL( mainWindow, getEnvironment() === Environment.Test ? await prepareFileUrl([__dirname, '../test/index.html']) : await prepareFileUrl([__dirname, '../background.html']) ); } // Renderer asks if we are done with the database ipc.handle('database-ready', async () => { if (!sqlInitPromise) { getLogger().error('database-ready requested, but sqlInitPromise is falsey'); return; } const { error } = await sqlInitPromise; if (error) { getLogger().error( 'database-ready requested, but got sql error', Errors.toLogFormat(error) ); return; } getLogger().info('sending `database-ready`'); }); ipc.handle( 'art-creator:uploadStickerPack', (_event: Electron.Event, data: unknown) => { const { promise, resolve } = explodePromise(); strictAssert(mainWindow, 'Main window did not exist'); mainWindow.webContents.send('art-creator:uploadStickerPack', data); ipc.once('art-creator:uploadStickerPack:done', (_doneEvent, response) => { resolve(response); }); return promise; } ); ipc.on('art-creator:onUploadProgress', () => { stickerCreatorWindow?.webContents.send('art-creator:onUploadProgress'); }); ipc.on('show-window', () => { showWindow(); }); ipc.on('title-bar-double-click', () => { if (!mainWindow) { return; } if (OS.isMacOS()) { switch ( systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string') ) { case 'Minimize': mainWindow.minimize(); break; case 'Maximize': toggleMaximizedBrowserWindow(mainWindow); break; default: // If this is disabled, it'll be 'None'. If it's anything else, that's unexpected, // but we'll just no-op. break; } } else { // This is currently only supported on macOS. This `else` branch is just here when/if // we add support for other operating systems. toggleMaximizedBrowserWindow(mainWindow); } }); ipc.on('set-is-call-active', (_event, isCallActive) => { preventDisplaySleepService.setEnabled(isCallActive); if (!mainWindow) { return; } let backgroundThrottling: boolean; if (isCallActive) { getLogger().info('Background throttling disabled because a call is active'); backgroundThrottling = false; } else { getLogger().info('Background throttling enabled because no call is active'); backgroundThrottling = true; } mainWindow.webContents.setBackgroundThrottling(backgroundThrottling); }); ipc.on('convert-image', async (event, uuid, data) => { const { error, response } = await heicConverter(uuid, data); event.reply(`convert-image:${uuid}`, { error, response }); }); let isReadyForUpdates = false; async function readyForUpdates() { if (isReadyForUpdates) { return; } isReadyForUpdates = true; // First, handle requested signal URLs const incomingHref = maybeGetIncomingSignalRoute(process.argv); if (incomingHref) { handleSignalRoute(incomingHref); } else if (macInitialOpenUrlRoute) { handleSignalRoute(macInitialOpenUrlRoute); } // Discard value even if we don't handle a saved URL. macInitialOpenUrlRoute = undefined; // Second, start checking for app updates try { strictAssert( settingsChannel !== undefined, 'SettingsChannel must be initialized' ); await updater.start({ settingsChannel, logger: getLogger(), getMainWindow, canRunSilently: () => { return ( systemTrayService?.isVisible() === true && mainWindow?.isVisible() !== true && mainWindow?.webContents?.getBackgroundThrottling() !== false ); }, }); } catch (error) { getLogger().error( 'Error starting update checks:', Errors.toLogFormat(error) ); } } async function forceUpdate() { try { getLogger().info('starting force update'); await updater.force(); } catch (error) { getLogger().error('Error during force update:', Errors.toLogFormat(error)); } } ipc.once('ready-for-updates', readyForUpdates); const TEN_MINUTES = 10 * 60 * 1000; setTimeout(readyForUpdates, TEN_MINUTES); function openContactUs() { drop(shell.openExternal(createSupportUrl({ locale: app.getLocale() }))); } function openJoinTheBeta() { // If we omit the language, the site will detect the language and redirect drop( shell.openExternal('https://support.signal.org/hc/articles/360007318471') ); } function openReleaseNotes() { if (mainWindow && mainWindow.isVisible()) { mainWindow.webContents.send('show-release-notes'); return; } drop( shell.openExternal( `https://github.com/signalapp/Signal-Desktop/releases/tag/v${app.getVersion()}` ) ); } function openSupportPage() { // If we omit the language, the site will detect the language and redirect drop( shell.openExternal('https://support.signal.org/hc/sections/360001602812') ); } function openForums() { drop(shell.openExternal('https://community.signalusers.org/')); } function showKeyboardShortcuts() { if (mainWindow) { mainWindow.webContents.send('show-keyboard-shortcuts'); } } function setupAsNewDevice() { if (mainWindow) { mainWindow.webContents.send('set-up-as-new-device'); } } function setupAsStandalone() { if (mainWindow) { mainWindow.webContents.send('set-up-as-standalone'); } } let screenShareWindow: BrowserWindow | undefined; async function showScreenShareWindow(sourceName: string | undefined) { if (screenShareWindow) { screenShareWindow.showInactive(); return; } const width = 480; const display = screen.getPrimaryDisplay(); const options = { alwaysOnTop: true, autoHideMenuBar: true, backgroundColor: '#2e2e2e', darkTheme: true, frame: false, fullscreenable: false, height: 44, maximizable: false, minimizable: false, resizable: false, show: false, title: getResolvedMessagesLocale().i18n('icu:screenShareWindow'), titleBarStyle: nonMainTitleBarStyle, width, webPreferences: { ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, sandbox: true, contextIsolation: true, preload: join(__dirname, '../bundles/screenShare/preload.js'), }, x: Math.floor(display.size.width / 2) - width / 2, y: 24, }; screenShareWindow = new BrowserWindow(options); await handleCommonWindowEvents(screenShareWindow); screenShareWindow.on('closed', () => { screenShareWindow = undefined; }); screenShareWindow.once('ready-to-show', () => { if (screenShareWindow) { screenShareWindow.show(); } }); await safeLoadURL( screenShareWindow, await prepareFileUrl([__dirname, '../screenShare.html'], { sourceName }) ); } let callingDevToolsWindow: BrowserWindow | undefined; async function showCallingDevToolsWindow() { if (callingDevToolsWindow) { callingDevToolsWindow.show(); return; } const options = { height: 1200, width: 1000, alwaysOnTop: false, autoHideMenuBar: true, backgroundColor: '#ffffff', darkTheme: false, frame: true, fullscreenable: true, maximizable: true, minimizable: true, resizable: true, show: false, title: getResolvedMessagesLocale().i18n('icu:callingDeveloperTools'), titleBarStyle: nonMainTitleBarStyle, webPreferences: { ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, sandbox: true, contextIsolation: true, nativeWindowOpen: true, preload: join(__dirname, '../bundles/calling-tools/preload.js'), }, }; callingDevToolsWindow = new BrowserWindow(options); await handleCommonWindowEvents(callingDevToolsWindow); callingDevToolsWindow.once('closed', () => { callingDevToolsWindow = undefined; mainWindow?.webContents.send('calling:set-rtc-stats-interval', null); }); ipc.on('calling:set-rtc-stats-interval', (_, intervalMillis: number) => { mainWindow?.webContents.send( 'calling:set-rtc-stats-interval', intervalMillis ); }); ipc.on('calling:rtc-stats-report', (_, report) => { callingDevToolsWindow?.webContents.send('calling:rtc-stats-report', report); }); await safeLoadURL( callingDevToolsWindow, await prepareFileUrl([__dirname, '../calling_tools.html']) ); callingDevToolsWindow.show(); } let aboutWindow: BrowserWindow | undefined; async function showAbout() { if (aboutWindow) { aboutWindow.show(); return; } const options = { width: 500, height: 500, resizable: false, title: getResolvedMessagesLocale().i18n('icu:aboutSignalDesktop'), titleBarStyle: nonMainTitleBarStyle, autoHideMenuBar: true, backgroundColor: await getBackgroundColor({ signalColors: true }), show: false, webPreferences: { ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, sandbox: true, contextIsolation: true, preload: join(__dirname, '../bundles/about/preload.js'), nativeWindowOpen: true, }, }; aboutWindow = new BrowserWindow(options); await handleCommonWindowEvents(aboutWindow); aboutWindow.on('closed', () => { aboutWindow = undefined; }); aboutWindow.once('ready-to-show', () => { if (aboutWindow) { aboutWindow.show(); } }); await safeLoadURL( aboutWindow, await prepareFileUrl([__dirname, '../about.html']) ); } let settingsWindow: BrowserWindow | undefined; async function showSettingsWindow() { if (settingsWindow) { settingsWindow.show(); return; } const options = { width: 700, height: 700, frame: true, resizable: false, title: getResolvedMessagesLocale().i18n('icu:signalDesktopPreferences'), titleBarStyle: mainTitleBarStyle, autoHideMenuBar: true, backgroundColor: await getBackgroundColor(), show: false, webPreferences: { ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, sandbox: true, contextIsolation: true, preload: join(__dirname, '../bundles/settings/preload.js'), nativeWindowOpen: true, }, }; settingsWindow = new BrowserWindow(options); await handleCommonWindowEvents(settingsWindow); settingsWindow.on('closed', () => { settingsWindow = undefined; }); ipc.once('settings-done-rendering', () => { if (!settingsWindow) { getLogger().warn('settings-done-rendering: no settingsWindow available!'); return; } settingsWindow.show(); }); await safeLoadURL( settingsWindow, await prepareFileUrl([__dirname, '../settings.html']) ); } async function getIsLinked() { try { const number = await sql.sqlRead('getItemById', 'number_id'); const password = await sql.sqlRead('getItemById', 'password'); return Boolean(number && password); } catch (e) { return false; } } async function openArtCreator() { if (!(await getIsLinked())) { const message = getResolvedMessagesLocale().i18n( 'icu:ArtCreator--Authentication--error' ); await dialog.showMessageBox({ type: 'warning', message, }); return; } await showStickerCreatorWindow(); } let debugLogWindow: BrowserWindow | undefined; async function showDebugLogWindow() { if (debugLogWindow) { doShowDebugLogWindow(); return; } function doShowDebugLogWindow() { if (debugLogWindow) { // Electron has [a macOS bug][0] that causes parent windows to become unresponsive // if it's fullscreen and opens a fullscreen child window. Until that's fixed, we // only set the parent on MacOS is if the mainWindow is not fullscreen // [0]: https://github.com/electron/electron/issues/32374 if (OS.isMacOS() && mainWindow?.isFullScreen()) { debugLogWindow.setParentWindow(null); } else { debugLogWindow.setParentWindow(mainWindow ?? null); } debugLogWindow.show(); } } const options: Electron.BrowserWindowConstructorOptions = { width: 700, height: 500, resizable: false, title: getResolvedMessagesLocale().i18n('icu:debugLog'), titleBarStyle: nonMainTitleBarStyle, autoHideMenuBar: true, backgroundColor: await getBackgroundColor(), show: false, webPreferences: { ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, sandbox: true, contextIsolation: true, preload: join(__dirname, '../bundles/debuglog/preload.js'), }, parent: mainWindow, }; debugLogWindow = new BrowserWindow(options); await handleCommonWindowEvents(debugLogWindow); debugLogWindow.on('closed', () => { debugLogWindow = undefined; }); debugLogWindow.once('ready-to-show', () => { if (debugLogWindow) { doShowDebugLogWindow(); // Electron sometimes puts the window in a strange spot until it's shown. debugLogWindow.center(); } }); await safeLoadURL( debugLogWindow, await prepareFileUrl([__dirname, '../debug_log.html']) ); } let permissionsPopupWindow: BrowserWindow | undefined; function showPermissionsPopupWindow(forCalling: boolean, forCamera: boolean) { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolveFn, reject) => { if (permissionsPopupWindow) { permissionsPopupWindow.show(); reject(new Error('Permission window already showing')); return; } if (!mainWindow) { reject(new Error('No main window')); return; } const size = mainWindow.getSize(); const options = { width: Math.min(400, size[0]), height: Math.min(150, size[1]), resizable: false, title: getResolvedMessagesLocale().i18n('icu:allowAccess'), titleBarStyle: nonMainTitleBarStyle, autoHideMenuBar: true, backgroundColor: await getBackgroundColor(), show: false, modal: true, webPreferences: { ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, sandbox: true, contextIsolation: true, preload: join(__dirname, '../bundles/permissions/preload.js'), nativeWindowOpen: true, }, parent: mainWindow, }; permissionsPopupWindow = new BrowserWindow(options); await handleCommonWindowEvents(permissionsPopupWindow); permissionsPopupWindow.on('closed', () => { removeDarkOverlay(); permissionsPopupWindow = undefined; resolveFn(); }); permissionsPopupWindow.once('ready-to-show', () => { if (permissionsPopupWindow) { addDarkOverlay(); permissionsPopupWindow.show(); } }); await safeLoadURL( permissionsPopupWindow, await prepareFileUrl([__dirname, '../permissions_popup.html'], { forCalling, forCamera, }) ); }); } const runSQLCorruptionHandler = async () => { // This is a glorified event handler. Normally, this promise never resolves, // but if there is a corruption error triggered by any query that we run // against the database - the promise will resolve and we will call // `onDatabaseError`. const error = await sql.whenCorrupted(); getLogger().error( 'Detected sql corruption in main process. ' + `Restarting the application immediately. Error: ${error.message}` ); await onDatabaseError(error); }; const runSQLReadonlyHandler = async () => { // This is a glorified event handler. Normally, this promise never resolves, // but if there is a corruption error triggered by any query that we run // against the database - the promise will resolve and we will call // `onDatabaseError`. const error = await sql.whenReadonly(); getLogger().error( `Detected readonly sql database in main process: ${error.message}` ); throw error; }; function generateSQLKey(): string { getLogger().info( 'key/initialize: Generating new encryption key, since we did not find it on disk' ); // https://www.zetetic.net/sqlcipher/sqlcipher-api/#key return randomBytes(32).toString('hex'); } function getSQLKey(): string { let update = false; const isLinux = OS.isLinux(); const legacyKeyValue = userConfig.get('key'); const modernKeyValue = userConfig.get('encryptedKey'); const previousBackend = isLinux ? userConfig.get('safeStorageBackend') : undefined; const safeStorageBackend: string | undefined = isLinux ? safeStorage.getSelectedStorageBackend() : undefined; const isEncryptionAvailable = safeStorage.isEncryptionAvailable() && (!isLinux || safeStorageBackend !== 'basic_text'); // On Linux the backend can change based on desktop environment and command line flags. // If the backend changes we won't be able to decrypt the key. if ( isLinux && typeof previousBackend === 'string' && previousBackend !== safeStorageBackend ) { console.error( `Detected change in safeStorage backend, can't decrypt DB key (previous: ${previousBackend}, current: ${safeStorageBackend})` ); throw new SafeStorageBackendChangeError({ currentBackend: String(safeStorageBackend), previousBackend, }); } let key: string; if (typeof modernKeyValue === 'string') { if (!isEncryptionAvailable) { throw new Error("Can't decrypt database key"); } getLogger().info('getSQLKey: decrypting key'); const encrypted = Buffer.from(modernKeyValue, 'hex'); key = safeStorage.decryptString(encrypted); if (legacyKeyValue != null) { getLogger().info('getSQLKey: removing legacy key'); userConfig.set('key', undefined); } if (isLinux && previousBackend == null) { getLogger().info( `getSQLKey: saving safeStorageBackend: ${safeStorageBackend}` ); userConfig.set('safeStorageBackend', safeStorageBackend); } } else if (typeof legacyKeyValue === 'string') { key = legacyKeyValue; update = isEncryptionAvailable; if (update) { getLogger().info('getSQLKey: migrating key'); } else { getLogger().info('getSQLKey: using legacy key'); } } else { getLogger().warn("getSQLKey: got key from config, but it wasn't a string"); key = generateSQLKey(); update = true; } if (!update) { return key; } if (isEncryptionAvailable) { getLogger().info('getSQLKey: updating encrypted key in the config'); const encrypted = safeStorage.encryptString(key).toString('hex'); userConfig.set('encryptedKey', encrypted); userConfig.set('key', undefined); if (isLinux && safeStorageBackend) { getLogger().info( `getSQLKey: saving safeStorageBackend: ${safeStorageBackend}` ); userConfig.set('safeStorageBackend', safeStorageBackend); } } else { getLogger().info('getSQLKey: updating plaintext key in the config'); userConfig.set('key', key); } return key; } async function initializeSQL( userDataPath: string ): Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> { sqlInitTimeStart = Date.now(); let key: string; try { key = getSQLKey(); } catch (error) { try { // Initialize with *some* key to setup paths await sql.initialize({ appVersion: app.getVersion(), configDir: userDataPath, key: 'abcd', logger: getLogger(), }); } catch { // Do nothing, we fail right below anyway. } if (error instanceof Error) { return { ok: false, error }; } return { ok: false, error: new Error(`initializeSQL: Caught a non-error '${error}'`), }; } try { // This should be the first awaited call in this function, otherwise // `sql.sqlRead` will throw an uninitialized error instead of waiting for // init to finish. await sql.initialize({ appVersion: app.getVersion(), configDir: userDataPath, key, logger: getLogger(), }); } catch (error: unknown) { if (error instanceof Error) { return { ok: false, error }; } return { ok: false, error: new Error(`initializeSQL: Caught a non-error '${error}'`), }; } finally { sqlInitTimeEnd = Date.now(); } sql.startTrackingQueryStats(); // Only if we've initialized things successfully do we set up the corruption handler drop(runSQLCorruptionHandler()); drop(runSQLReadonlyHandler()); return { ok: true, error: undefined }; } const onDatabaseError = async (error: Error) => { // Prevent window from re-opening ready = false; if (mainWindow) { mainWindow.close(); } mainWindow = undefined; const { i18n } = getResolvedMessagesLocale(); let deleteAllDataButtonIndex: number | undefined; let messageDetail: string; const buttons = [i18n('icu:copyErrorAndQuit')]; const copyErrorAndQuitButtonIndex = 0; const SIGNAL_SUPPORT_LINK = 'https://support.signal.org/error'; // Note that this error is thrown by the worker process and thus instanceof // check won't work. if (error.name === 'DBVersionFromFutureError') { // If the DB version is too new, the user likely opened an older version of Signal, // and they would almost never want to delete their data as a result, so we don't show // that option messageDetail = i18n('icu:databaseError__startOldVersion'); } else if (error instanceof SafeStorageBackendChangeError) { const { currentBackend, previousBackend } = error; const previousBackendFlag = getOwn( LINUX_PASSWORD_STORE_FLAGS, previousBackend ); messageDetail = previousBackendFlag ? i18n('icu:databaseError__safeStorageBackendChangeWithPreviousFlag', { currentBackend, previousBackend, previousBackendFlag, }) : i18n('icu:databaseError__safeStorageBackendChange', { currentBackend, previousBackend, }); } else { // Otherwise, this is some other kind of DB error, let's give them the option to // delete. messageDetail = i18n( 'icu:databaseError__detail', { link: SIGNAL_SUPPORT_LINK }, { bidi: 'strip' } ); buttons.push(i18n('icu:deleteAndRestart')); deleteAllDataButtonIndex = 1; } const buttonIndex = dialog.showMessageBoxSync({ buttons, defaultId: copyErrorAndQuitButtonIndex, cancelId: copyErrorAndQuitButtonIndex, message: i18n('icu:databaseError'), detail: messageDetail, noLink: true, type: 'error', }); if (buttonIndex === copyErrorAndQuitButtonIndex) { clipboard.writeText( `Database startup error:\n\n${redactAll(Errors.toLogFormat(error))}\n\n` + `App Version: ${app.getVersion()}\n` + `OS: ${os.platform()}` ); } else if ( typeof deleteAllDataButtonIndex === 'number' && buttonIndex === deleteAllDataButtonIndex ) { const confirmationButtons = [ i18n('icu:cancel'), i18n('icu:deleteAndRestart'), ]; const cancelButtonIndex = 0; const confirmDeleteAllDataButtonIndex = 1; const confirmationButtonIndex = dialog.showMessageBoxSync({ buttons: confirmationButtons, defaultId: cancelButtonIndex, cancelId: cancelButtonIndex, message: i18n('icu:databaseError__deleteDataConfirmation'), detail: i18n('icu:databaseError__deleteDataConfirmation__detail'), noLink: true, type: 'warning', }); if (confirmationButtonIndex === confirmDeleteAllDataButtonIndex) { getLogger().error('onDatabaseError: Deleting all data'); await sql.removeDB(); userConfig.remove(); getLogger().error( 'onDatabaseError: Requesting immediate restart after quit' ); app.relaunch(); } } getLogger().error('onDatabaseError: Quitting application'); app.exit(1); }; let sqlInitPromise: | Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> | undefined; ipc.on('database-readonly', (_event: Electron.Event, error: string) => { // Just let global_errors.ts handle it throw new Error(error); }); function loadPreferredSystemLocales(): Array { if (CLI_LANG != null) { try { // Normalizes locales so its safe to pass them into Intl apis. return Intl.getCanonicalLocales(CLI_LANG); } catch { // Ignore, totally invalid locale, fallback to system languages. } } if (getEnvironment() === Environment.Test) { return ['en']; } return app.getPreferredSystemLanguages(); } async function getDefaultLoginItemSettings(): Promise { if (!OS.isWindows()) { return {}; } const systemTraySetting = await systemTraySettingCache.get(); if ( systemTraySetting !== SystemTraySetting.MinimizeToSystemTray && // This is true when we just started with `--start-in-tray` systemTraySetting !== SystemTraySetting.MinimizeToAndStartInSystemTray ) { return {}; } // The effect of this is that if both auto-launch and minimize to system tray // are enabled on Windows - we will start the app in tray automatically, // letting the Desktop shortcuts still start the Signal not in tray. return { args: ['--start-in-tray'] }; } // Signal doesn't really use media keys so we set this switch here to unblock // them so that other apps can use them if they need to. const featuresToDisable = `HardwareMediaKeyHandling,${app.commandLine.getSwitchValue( 'disable-features' )}`; app.commandLine.appendSwitch('disable-features', featuresToDisable); // rendering is often utterly broken on Linux when using GPU // acceleration. if (DISABLE_GPU) { app.disableHardwareAcceleration(); } // This has to run before the 'ready' event. electronProtocol.registerSchemesAsPrivileged([ { scheme: 'attachment', privileges: { supportFetchAPI: true, stream: true, }, }, ]); // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. let ready = false; app.on('ready', async () => { dns.setFallback(await getDNSFallback()); if (DISABLE_IPV6) { dns.setIPv6Enabled(false); } const [userDataPath, crashDumpsPath, installPath] = await Promise.all([ realpath(app.getPath('userData')), realpath(app.getPath('crashDumps')), realpath(app.getAppPath()), ]); updateDefaultSession(session.defaultSession, getLogger); if (getEnvironment() !== Environment.Test) { installFileHandler({ session: session.defaultSession, userDataPath, installPath, isWindows: OS.isWindows(), }); } installWebHandler({ enableHttp: Boolean(process.env.SIGNAL_ENABLE_HTTP), session: session.defaultSession, }); logger = await logging.initialize(getMainWindow); // Write buffered information into newly created logger. consoleLogger.writeBufferInto(logger); const resourceService = OptionalResourceService.create( join(userDataPath, 'optionalResources') ); await EmojiService.create(resourceService); if (!resolvedTranslationsLocale) { preferredSystemLocales = resolveCanonicalLocales( loadPreferredSystemLocales() ); localeOverride = await getLocaleOverrideSetting(); const hourCyclePreference = getHourCyclePreference(); logger.info(`app.ready: hour cycle preference: ${hourCyclePreference}`); logger.info( `app.ready: preferred system locales: ${preferredSystemLocales.join( ', ' )}` ); resolvedTranslationsLocale = loadLocale({ preferredSystemLocales, localeOverride, localeDirectionTestingOverride, hourCyclePreference, logger: getLogger(), }); } sqlInitPromise = initializeSQL(userDataPath); // First run: configure Signal to minimize to tray. Additionally, on Windows // enable auto-start with start-in-tray so that starting from a Desktop icon // would still show the window. // (User can change these settings later) if ( isSystemTraySupported(OS) && (await systemTraySettingCache.get()) === SystemTraySetting.Uninitialized ) { const newValue = getDefaultSystemTraySetting(OS, app.getVersion()); getLogger().info(`app.ready: setting system-tray-setting to ${newValue}`); systemTraySettingCache.set(newValue); ephemeralConfig.set('system-tray-setting', newValue); if (OS.isWindows()) { getLogger().info('app.ready: enabling open at login'); app.setLoginItemSettings({ ...(await getDefaultLoginItemSettings()), openAtLogin: true, }); } } const startTime = Date.now(); settingsChannel = new SettingsChannel(); settingsChannel.install(); settingsChannel.on('change:systemTraySetting', async rawSystemTraySetting => { const { openAtLogin } = app.getLoginItemSettings( await getDefaultLoginItemSettings() ); const systemTraySetting = parseSystemTraySetting(rawSystemTraySetting); systemTraySettingCache.set(systemTraySetting); if (systemTrayService) { const isEnabled = shouldMinimizeToSystemTray(systemTraySetting); systemTrayService.setEnabled(isEnabled); } // Default login item settings might have changed, so update the object. getLogger().info('refresh-auto-launch: new value', openAtLogin); app.setLoginItemSettings({ ...(await getDefaultLoginItemSettings()), openAtLogin, }); }); settingsChannel.on( 'ephemeral-setting-changed', sendPreferencesChangedEventToWindows ); // We use this event only a single time to log the startup time of the app // from when it's first ready until the loading screen disappears. ipc.once('signal-app-loaded', (event, info) => { const { preloadTime, connectTime, processedCount } = info; const loadTime = Date.now() - startTime; const sqlInitTime = sqlInitTimeEnd - sqlInitTimeStart; const messageTime = loadTime - preloadTime - connectTime; const messagesPerSec = (processedCount * 1000) / messageTime; const innerLogger = getLogger(); innerLogger.info('App loaded - time:', loadTime); innerLogger.info('SQL init - time:', sqlInitTime); innerLogger.info('Preload - time:', preloadTime); innerLogger.info('WebSocket connect - time:', connectTime); innerLogger.info('Processed count:', processedCount); innerLogger.info('Messages per second:', messagesPerSec); sql.stopTrackingQueryStats(); event.sender.send('ci:event', 'app-loaded', { loadTime, sqlInitTime, preloadTime, connectTime, processedCount, messagesPerSec, }); }); addSensitivePath(userDataPath); addSensitivePath(crashDumpsPath); if (getEnvironment() !== Environment.Test) { installFileHandler({ session: session.defaultSession, userDataPath, installPath, isWindows: OS.isWindows(), }); } logger.info('app ready'); logger.info(`starting version ${packageJson.version}`); // This logging helps us debug user reports about broken devices. { let getMediaAccessStatus; // This function is not supported on Linux, so we have a fallback. if (systemPreferences.getMediaAccessStatus) { getMediaAccessStatus = systemPreferences.getMediaAccessStatus.bind(systemPreferences); } else { getMediaAccessStatus = noop; } logger.info( 'media access status', getMediaAccessStatus('microphone'), getMediaAccessStatus('camera') ); } GlobalErrors.updateLocale(resolvedTranslationsLocale); // If the sql initialization takes more than three seconds to complete, we // want to notify the user that things are happening const timeout = new Promise(resolveFn => setTimeout(resolveFn, 3000, 'timeout') ); // This color is to be used only in loading screen and in this case we should // never wait for the database to be initialized. Thus the theme setting // lookup should be done only in ephemeral config. const backgroundColor = await getBackgroundColor({ ephemeralOnly: true, signalColors: true, }); drop( // eslint-disable-next-line more/no-then Promise.race([sqlInitPromise, timeout]).then(async maybeTimeout => { if (maybeTimeout !== 'timeout') { return; } getLogger().info( 'sql.initialize is taking more than three seconds; showing loading dialog' ); loadingWindow = new BrowserWindow({ show: false, width: 300, height: 280, resizable: false, frame: false, backgroundColor, webPreferences: { ...defaultWebPrefs, nodeIntegration: false, sandbox: true, contextIsolation: true, preload: join(__dirname, '../bundles/loading/preload.js'), }, icon: windowIcon, }); loadingWindow.once('ready-to-show', async () => { if (!loadingWindow) { return; } loadingWindow.show(); // Wait for sql initialization to complete, but ignore errors await sqlInitPromise; loadingWindow.destroy(); loadingWindow = undefined; }); await safeLoadURL( loadingWindow, await prepareFileUrl([__dirname, '../loading.html']) ); }) ); try { await attachments.clearTempPath(userDataPath); } catch (err) { logger.error( 'main/ready: Error deleting temp dir:', Errors.toLogFormat(err) ); } try { await attachments.deleteStaleDownloads(userDataPath); } catch (err) { logger.error( 'main/ready: Error deleting stale downloads:', Errors.toLogFormat(err) ); } // Initialize IPC channels before creating the window attachmentChannel.initialize({ sql, configDir: userDataPath, }); sqlChannels.initialize(sql); PowerChannel.initialize({ send(event) { if (!mainWindow) { return; } mainWindow.webContents.send(event); }, }); // Run window preloading in parallel with database initialization. await createWindow(); const { error: sqlError } = await sqlInitPromise; if (sqlError) { getLogger().error('sql.initialize was unsuccessful; returning early'); await onDatabaseError(sqlError); return; } appStartInitialSpellcheckSetting = await getSpellCheckSetting(); try { const IDB_KEY = 'indexeddb-delete-needed'; const item = await sql.sqlRead('getItemById', IDB_KEY); if (item && item.value) { await sql.sqlWrite('removeIndexedDBFiles'); await sql.sqlWrite('removeItemById', IDB_KEY); } } catch (err) { getLogger().error( '(ready event handler) error deleting IndexedDB:', Errors.toLogFormat(err) ); } ready = true; setupMenu(); systemTrayService = new SystemTrayService({ i18n: resolvedTranslationsLocale.i18n, }); systemTrayService.setMainWindow(mainWindow); systemTrayService.setEnabled( shouldMinimizeToSystemTray(await systemTraySettingCache.get()) ); await ensureFilePermissions([ 'config.json', 'sql/db.sqlite', 'sql/db.sqlite-wal', 'sql/db.sqlite-shm', ]); }); function setupMenu(options?: Partial) { const { platform } = process; menuOptions = { // options development, devTools: defaultWebPrefs.devTools, includeSetup: false, isProduction: isProduction(app.getVersion()), platform, // actions forceUpdate, openArtCreator, openContactUs, openForums, openJoinTheBeta, openReleaseNotes, openSupportPage, setupAsNewDevice, setupAsStandalone, showAbout, showDebugLog: showDebugLogWindow, showCallingDevTools: showCallingDevToolsWindow, showKeyboardShortcuts, showSettings: showSettingsWindow, showWindow, zoomIn, zoomOut, zoomReset, // overrides ...options, }; const template = createTemplate( menuOptions, getResolvedMessagesLocale().i18n ); const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); mainWindow?.webContents.send('window:set-menu-options', { development: menuOptions.development, devTools: menuOptions.devTools, includeSetup: menuOptions.includeSetup, isProduction: menuOptions.isProduction, platform: menuOptions.platform, }); } async function maybeRequestCloseConfirmation(): Promise { if (!mainWindow || !mainWindow.webContents) { return true; } getLogger().info( 'maybeRequestCloseConfirmation: Checking to see if close confirmation is needed' ); const request = new Promise(resolveFn => { let timeout: NodeJS.Timeout | undefined; if (!mainWindow) { resolveFn(true); return; } ipc.once('received-close-confirmation', (_event, result) => { getLogger().info('maybeRequestCloseConfirmation: Response received'); clearTimeoutIfNecessary(timeout); resolveFn(result); }); ipc.once('requested-close-confirmation', () => { getLogger().info( 'maybeRequestCloseConfirmation: Confirmation dialog shown, waiting for user.' ); clearTimeoutIfNecessary(timeout); }); mainWindow.webContents.send('maybe-request-close-confirmation'); // Wait a short time then proceed. Normally the dialog should be // shown right away. timeout = setTimeout(() => { getLogger().error( 'maybeRequestCloseConfirmation: Response never received; continuing with close.' ); resolveFn(true); }, 10 * 1000); }); try { return await request; } catch (error) { getLogger().error( 'maybeRequestCloseConfirmation error:', Errors.toLogFormat(error) ); return true; } } async function requestShutdown() { if (!mainWindow || !mainWindow.webContents) { return; } getLogger().info('requestShutdown: Requesting close of mainWindow...'); const request = new Promise(resolveFn => { let timeout: NodeJS.Timeout | undefined; if (!mainWindow) { resolveFn(); return; } ipc.once('now-ready-for-shutdown', (_event, error) => { getLogger().info('requestShutdown: Response received'); if (error) { getLogger().error( 'requestShutdown: got error, still shutting down.', error ); } clearTimeoutIfNecessary(timeout); resolveFn(); }); mainWindow.webContents.send('get-ready-for-shutdown'); // We'll wait two minutes, then force the app to go down. This can happen if someone // exits the app before we've set everything up in preload() (so the browser isn't // yet listening for these events), or if there are a whole lot of stacked-up tasks. // Note: two minutes is also our timeout for SQL tasks in data.js in the browser. timeout = setTimeout( () => { getLogger().error( 'requestShutdown: Response never received; forcing shutdown.' ); resolveFn(); }, 2 * 60 * 1000 ); }); try { await request; } catch (error) { getLogger().error('requestShutdown error:', Errors.toLogFormat(error)); } } function getWindowDebugInfo() { const windows = BrowserWindow.getAllWindows(); try { return { windowCount: windows.length, mainWindowExists: windows.some(win => win === mainWindow), mainWindowIsFullScreen: mainWindow?.isFullScreen(), }; } catch { return { windowCount: 0, mainWindowExists: false, mainWindowIsFullScreen: false, }; } } app.on('before-quit', e => { getLogger().info('before-quit event', { readyForShutdown: windowState.readyForShutdown(), shouldQuit: windowState.shouldQuit(), hasEventBeenPrevented: e.defaultPrevented, ...getWindowDebugInfo(), }); systemTrayService?.markShouldQuit(); windowState.markShouldQuit(); }); app.on('will-quit', e => { getLogger().info('will-quit event', { hasEventBeenPrevented: e.defaultPrevented, ...getWindowDebugInfo(), }); }); app.on('quit', e => { getLogger().info('quit event', { hasEventBeenPrevented: e.defaultPrevented, ...getWindowDebugInfo(), }); }); // Quit when all windows are closed. app.on('window-all-closed', () => { getLogger().info('main process handling window-all-closed'); // On OS X it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q const shouldAutoClose = !OS.isMacOS() || isTestEnvironment(getEnvironment()); // Only automatically quit if the main window has been created // This is necessary because `window-all-closed` can be triggered by the // "optimizing application" window closing if (shouldAutoClose && mainWindowCreated) { app.quit(); } }); app.on('activate', () => { if (!ready) { return; } // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (mainWindow) { mainWindow.show(); } else { drop(createWindow()); } }); // Defense in depth. We never intend to open webviews or windows. Prevent it completely. app.on( 'web-contents-created', (_createEvent: Electron.Event, contents: Electron.WebContents) => { contents.on('will-attach-webview', attachEvent => { attachEvent.preventDefault(); }); contents.setWindowOpenHandler(() => ({ action: 'deny' })); } ); app.setAsDefaultProtocolClient('sgnl'); app.setAsDefaultProtocolClient('signalcaptcha'); ipc.on( 'set-badge', (_event: Electron.Event, badge: number | 'marked-unread') => { if (badge === 'marked-unread') { if (process.platform === 'darwin') { // Will show a ● on macOS when undefined app.setBadgeCount(undefined); } else { // All other OS's need a number app.setBadgeCount(1); } } else { app.setBadgeCount(badge); } } ); ipc.on('remove-setup-menu-items', () => { setupMenu(); }); ipc.on('add-setup-menu-items', () => { setupMenu({ includeSetup: true, }); }); ipc.on('draw-attention', () => { if (!mainWindow) { return; } if (OS.isWindows() || OS.isLinux()) { mainWindow.flashFrame(true); } }); ipc.on('restart', () => { getLogger().info('Relaunching application'); app.relaunch(); app.quit(); }); ipc.on('shutdown', () => { if (process.env.GENERATE_PRELOAD_CACHE) { windowState.markReadyForShutdown(); } app.quit(); }); ipc.on( 'set-auto-hide-menu-bar', (_event: Electron.Event, autoHide: boolean) => { if (mainWindow) { mainWindow.autoHideMenuBar = autoHide; } } ); ipc.on( 'set-menu-bar-visibility', (_event: Electron.Event, visibility: boolean) => { if (mainWindow) { mainWindow.setMenuBarVisibility(visibility); } } ); ipc.on( 'screen-share:status-change', (_event: Electron.Event, status: ScreenShareStatus) => { if (!screenShareWindow) { return; } if (status === ScreenShareStatus.Disconnected) { screenShareWindow.close(); } else { screenShareWindow.webContents.send('status-change', status); } } ); ipc.on('stop-screen-share', () => { if (mainWindow) { mainWindow.webContents.send('stop-screen-share'); } }); ipc.on( 'show-screen-share', (_event: Electron.Event, sourceName: string | undefined) => { drop(showScreenShareWindow(sourceName)); } ); ipc.on('update-tray-icon', (_event: Electron.Event, unreadCount: number) => { if (systemTrayService) { systemTrayService.setUnreadCount(unreadCount); } }); // Debug Log-related IPC calls ipc.on('show-debug-log', showDebugLogWindow); ipc.on( 'show-debug-log-save-dialog', async (_event: Electron.Event, logText: string) => { const { filePath } = await dialog.showSaveDialog({ defaultPath: 'debuglog.txt', showsTagField: false, }); if (filePath) { await writeFile(filePath, logText); } } ); // Permissions Popup-related IPC calls ipc.handle( 'show-permissions-popup', async (_event: Electron.Event, forCalling: boolean, forCamera: boolean) => { try { await showPermissionsPopupWindow(forCalling, forCamera); } catch (error) { getLogger().error( 'show-permissions-popup error:', Errors.toLogFormat(error) ); } } ); // Settings-related IPC calls function addDarkOverlay() { if (mainWindow && mainWindow.webContents) { mainWindow.webContents.send('add-dark-overlay'); } } function removeDarkOverlay() { if (mainWindow && mainWindow.webContents) { mainWindow.webContents.send('remove-dark-overlay'); } } ipc.on('show-settings', showSettingsWindow); ipc.on('delete-all-data', () => { if (settingsWindow) { settingsWindow.close(); } if (mainWindow && mainWindow.webContents) { mainWindow.webContents.send('delete-all-data'); } }); ipc.on('get-config', async event => { const theme = await getResolvedThemeSetting(); const directoryConfig = safeParseLoose(directoryConfigSchema, { directoryUrl: config.get('directoryUrl') || undefined, directoryMRENCLAVE: config.get('directoryMRENCLAVE') || undefined, }); if (!directoryConfig.success) { throw new Error( `prepareUrl: Failed to parse renderer directory config ${JSON.stringify( directoryConfig.error.flatten() )}` ); } const parsed = safeParseLoose(rendererConfigSchema, { name: packageJson.productName, availableLocales: getResolvedMessagesLocale().availableLocales, resolvedTranslationsLocale: getResolvedMessagesLocale().name, resolvedTranslationsLocaleDirection: getResolvedMessagesLocale().direction, hourCyclePreference: getResolvedMessagesLocale().hourCyclePreference, preferredSystemLocales: getPreferredSystemLocales(), localeOverride: getLocaleOverride(), version: app.getVersion(), buildCreation: config.get('buildCreation'), buildExpiration: config.get('buildExpiration'), challengeUrl: config.get('challengeUrl'), serverUrl: config.get('serverUrl'), storageUrl: config.get('storageUrl'), updatesUrl: config.get('updatesUrl'), resourcesUrl: config.get('resourcesUrl'), cdnUrl0: config.get('cdn.0'), cdnUrl2: config.get('cdn.2'), cdnUrl3: config.get('cdn.3'), certificateAuthority: config.get('certificateAuthority'), environment: !isTestEnvironment(getEnvironment()) && ciMode ? Environment.PackagedApp : getEnvironment(), isMockTestEnvironment: Boolean(process.env.MOCK_TEST), ciMode, devTools: defaultWebPrefs.devTools, // Should be already computed and cached at this point dnsFallback: await getDNSFallback(), disableIPv6: DISABLE_IPV6, ciBackupPath: config.get('ciBackupPath') || undefined, ciIsBackupIntegration: config.get('ciIsBackupIntegration'), nodeVersion: process.versions.node, hostname: os.hostname(), osRelease: os.release(), osVersion: os.version(), appInstance: process.env.NODE_APP_INSTANCE || undefined, proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy || undefined, contentProxyUrl: config.get('contentProxyUrl'), sfuUrl: config.get('sfuUrl'), reducedMotionSetting: DISABLE_GPU || animationSettings.prefersReducedMotion, registrationChallengeUrl: config.get('registrationChallengeUrl'), serverPublicParams: config.get('serverPublicParams'), serverTrustRoot: config.get('serverTrustRoot'), genericServerPublicParams: config.get('genericServerPublicParams'), backupServerPublicParams: config.get('backupServerPublicParams'), theme, appStartInitialSpellcheckSetting, // paths crashDumpsPath: app.getPath('crashDumps'), homePath: app.getPath('home'), installPath: app.getAppPath(), userDataPath: app.getPath('userData'), directoryConfig: directoryConfig.data, // Only used by the main window isMainWindowFullScreen: Boolean(mainWindow?.isFullScreen()), isMainWindowMaximized: Boolean(mainWindow?.isMaximized()), // Only for tests argv: JSON.stringify(process.argv), } satisfies RendererConfigType); if (!parsed.success) { throw new Error( `prepareUrl: Failed to parse renderer config ${JSON.stringify( parsed.error.flatten() )}` ); } // eslint-disable-next-line no-param-reassign event.returnValue = parsed.data; }); // Ingested in preload.js via a sendSync call ipc.on('locale-data', event => { // eslint-disable-next-line no-param-reassign event.returnValue = getResolvedMessagesLocale().messages; }); // Ingested in preload.js via a sendSync call ipc.on('locale-display-names', event => { // eslint-disable-next-line no-param-reassign event.returnValue = getResolvedMessagesLocale().localeDisplayNames; }); // Ingested in preload.js via a sendSync call ipc.on('country-display-names', event => { // eslint-disable-next-line no-param-reassign event.returnValue = getResolvedMessagesLocale().countryDisplayNames; }); // TODO DESKTOP-5241 ipc.on('OS.getClassName', event => { // eslint-disable-next-line no-param-reassign event.returnValue = OS.getClassName(); }); ipc.handle( 'DebugLogs.getLogs', async (_event, data: unknown, userAgent: string) => { return debugLog.getLog( data, process.versions.node, app.getVersion(), os.version(), userAgent, OS.getLinuxName() ); } ); ipc.handle('DebugLogs.upload', async (_event, content: string) => { return uploadDebugLog.upload({ content, appVersion: app.getVersion(), logger: getLogger(), }); }); ipc.on('get-user-data-path', event => { // eslint-disable-next-line no-param-reassign event.returnValue = app.getPath('userData'); }); // Refresh the settings window whenever preferences change const sendPreferencesChangedEventToWindows = () => { for (const window of activeWindows) { if (window.webContents) { window.webContents.send('preferences-changed'); } } }; ipc.on('preferences-changed', sendPreferencesChangedEventToWindows); function maybeGetIncomingSignalRoute(argv: Array) { for (const arg of argv) { const route = parseSignalRoute(arg); if (route != null) { return route; } } return null; } function handleSignalRoute(route: ParsedSignalRoute) { const log = getLogger(); if (mainWindow == null || !mainWindow.webContents) { log.error('handleSignalRoute: mainWindow is null or missing webContents'); return; } log.info('handleSignalRoute: Matched signal route:', route.key); if (route.key === 'artAddStickers') { mainWindow.webContents.send('show-sticker-pack', { packId: route.args.packId, packKey: Buffer.from(route.args.packKey, 'hex').toString('base64'), }); } else if (route.key === 'groupInvites') { mainWindow.webContents.send('show-group-via-link', { value: route.args.inviteCode, }); } else if (route.key === 'contactByPhoneNumber') { mainWindow.webContents.send('show-conversation-via-signal.me', { kind: 'phoneNumber', value: route.args.phoneNumber, }); } else if (route.key === 'contactByEncryptedUsername') { mainWindow.webContents.send('show-conversation-via-signal.me', { kind: 'encryptedUsername', value: route.args.encryptedUsername, }); } else if (route.key === 'showConversation') { mainWindow.webContents.send('show-conversation-via-notification', { conversationId: route.args.conversationId, messageId: route.args.messageId, storyId: route.args.storyId, }); } else if (route.key === 'startCallLobby') { mainWindow.webContents.send('start-call-lobby', { conversationId: route.args.conversationId, }); } else if (route.key === 'linkCall') { mainWindow.webContents.send('start-call-link', { key: route.args.key, }); } else if (route.key === 'showWindow') { mainWindow.webContents.send('show-window'); } else if (route.key === 'cancelPresenting') { mainWindow.webContents.send('cancel-presenting'); } else if (route.key === 'captcha') { challengeHandler.handleCaptcha(route.args.captchaId); // Show window after handling captcha showWindow(); } else { log.info('handleSignalRoute: Unknown signal route:', route.key); mainWindow.webContents.send('unknown-sgnl-link'); } } ipc.handle('install-sticker-pack', (_event, packId, packKeyHex) => { const packKey = Buffer.from(packKeyHex, 'hex').toString('base64'); if (mainWindow) { mainWindow.webContents.send('install-sticker-pack', { packId, packKey }); } }); ipc.handle('ensure-file-permissions', () => ensureFilePermissions()); /** * Ensure files in the user's data directory have the proper permissions. * Optionally takes an array of file paths to exclusively affect. * * @param {string[]} [onlyFiles] - Only ensure permissions on these given files */ async function ensureFilePermissions(onlyFiles?: Array) { getLogger().info('Begin ensuring permissions'); const start = Date.now(); const userDataPath = await realpath(app.getPath('userData')); // fast-glob uses `/` for all platforms const userDataGlob = normalizePath(join(userDataPath, '**', '*')); // Determine files to touch const files = onlyFiles ? onlyFiles.map(f => join(userDataPath, f)) : await fastGlob(userDataGlob, { markDirectories: true, onlyFiles: false, ignore: ['**/Singleton*'], }); getLogger().info(`Ensuring file permissions for ${files.length} files`); // Touch each file in a queue const q = new PQueue({ concurrency: 5, timeout: 1000 * 60 * 2 }); drop( q.addAll( files.map(f => async () => { const isDir = f.endsWith('/'); try { await chmod(normalize(f), isDir ? 0o700 : 0o600); } catch (error) { getLogger().error( 'ensureFilePermissions: Error from chmod', error.message ); } }) ) ); await q.onEmpty(); getLogger().info(`Finish ensuring permissions in ${Date.now() - start}ms`); } ipc.handle('get-media-access-status', async (_event, value) => { // This function is not supported on Linux if (!systemPreferences.getMediaAccessStatus) { return undefined; } return systemPreferences.getMediaAccessStatus(value); }); ipc.handle('get-auto-launch', async () => { return app.getLoginItemSettings(await getDefaultLoginItemSettings()) .openAtLogin; }); ipc.handle('set-auto-launch', async (_event, value) => { const openAtLogin = Boolean(value); getLogger().info('set-auto-launch: new value', openAtLogin); app.setLoginItemSettings({ ...(await getDefaultLoginItemSettings()), openAtLogin, }); }); ipc.on('show-message-box', (_event, { type, message }) => { drop(dialog.showMessageBox({ type, message })); }); ipc.on('show-item-in-folder', (_event, folder) => { shell.showItemInFolder(folder); }); ipc.handle('show-save-dialog', async (_event, { defaultPath }) => { if (!mainWindow) { getLogger().warn('show-save-dialog: no main window'); return { canceled: true }; } const { canceled, filePath: selectedFilePath } = await dialog.showSaveDialog( mainWindow, { defaultPath, showsTagField: false, } ); if (canceled || selectedFilePath == null) { return { canceled: true }; } // On Windows, if you change the path from the default, the extension is // removed. We want to make sure the extension is always there. const defaultExt = extname(defaultPath); const finalDirname = dirname(selectedFilePath); const finalBasename = basename(selectedFilePath, defaultExt); const finalFilePath = join(finalDirname, `${finalBasename}${defaultExt}`); return { canceled: false, filePath: finalFilePath }; }); ipc.handle('executeMenuRole', async ({ sender }, untypedRole) => { const role = untypedRole as MenuItemConstructorOptions['role']; const senderWindow = BrowserWindow.fromWebContents(sender); switch (role) { case 'undo': sender.undo(); break; case 'redo': sender.redo(); break; case 'cut': sender.cut(); break; case 'copy': sender.copy(); break; case 'paste': sender.paste(); break; case 'pasteAndMatchStyle': sender.pasteAndMatchStyle(); break; case 'delete': sender.delete(); break; case 'selectAll': sender.selectAll(); break; case 'reload': sender.reload(); break; case 'toggleDevTools': sender.toggleDevTools(); break; case 'togglefullscreen': senderWindow?.setFullScreen(!senderWindow?.isFullScreen()); break; case 'minimize': senderWindow?.minimize(); break; case 'close': senderWindow?.close(); break; case 'quit': app.quit(); break; default: // ignored break; } }); ipc.handle('getMainWindowStats', async () => { return { isMaximized: windowConfig?.maximized ?? false, isFullScreen: windowConfig?.fullscreen ?? false, }; }); ipc.handle('getMenuOptions', async () => { return { development: menuOptions?.development ?? false, devTools: menuOptions?.devTools ?? false, includeSetup: menuOptions?.includeSetup ?? false, isProduction: menuOptions?.isProduction ?? true, platform: menuOptions?.platform ?? 'unknown', }; }); async function zoomIn() { await zoomFactorService.zoomIn(); } async function zoomOut() { await zoomFactorService.zoomOut(); } async function zoomReset() { await zoomFactorService.zoomReset(); } ipc.handle( 'net.resolveHost', (_event, hostname: string, queryType?: 'A' | 'AAAA') => { return net.resolveHost(hostname, { queryType, }); } ); let stickerCreatorWindow: BrowserWindow | undefined; async function showStickerCreatorWindow() { if (stickerCreatorWindow) { stickerCreatorWindow.show(); return; } const { x = 0, y = 0 } = windowConfig || {}; const options = { x: x + 100, y: y + 100, width: 800, minWidth: 800, height: 815, minHeight: 750, frame: true, title: getResolvedMessagesLocale().i18n('icu:signalDesktopStickerCreator'), autoHideMenuBar: true, backgroundColor: await getBackgroundColor(), show: false, webPreferences: { ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, sandbox: true, contextIsolation: true, preload: join(__dirname, '../ts/windows/sticker-creator/preload.js'), nativeWindowOpen: true, }, }; stickerCreatorWindow = new BrowserWindow(options); await handleCommonWindowEvents(stickerCreatorWindow); stickerCreatorWindow.once('ready-to-show', () => { stickerCreatorWindow?.show(); }); stickerCreatorWindow.on('closed', () => { stickerCreatorWindow = undefined; }); await safeLoadURL( stickerCreatorWindow, await prepareFileUrl([__dirname, '../sticker-creator/dist/index.html']) ); } if (isTestEnvironment(getEnvironment())) { ipc.on('ci:test-electron:getArgv', event => { // eslint-disable-next-line no-param-reassign event.returnValue = process.argv; }); ipc.handle('ci:test-electron:debug', async (_event, info) => { process.stdout.write(`ci:test-electron:debug=${JSON.stringify(info)}\n`); }); ipc.handle('ci:test-electron:event', async (_event, event) => { process.stdout.write( `ci:test-electron:event=${JSON.stringify(event)}\n`, () => { if (event.type !== 'end') { return; } if (!process.env.TEST_QUIT_ON_COMPLETE) { return; } app.quit(); } ); }); }