diff --git a/app/main.ts b/app/main.ts index 1addcb217e12..1f6cc02ba2b7 100644 --- a/app/main.ts +++ b/app/main.ts @@ -117,6 +117,7 @@ import { HourCyclePreference } from '../ts/types/I18N'; import { DBVersionFromFutureError } from '../ts/sql/migrations'; import type { ParsedSignalRoute } from '../ts/util/signalRoutes'; import { parseSignalRoute } from '../ts/util/signalRoutes'; +import { ZoomFactorService } from '../ts/services/ZoomFactorService'; const STICKER_CREATOR_PARTITION = 'sticker-creator'; @@ -200,6 +201,7 @@ const defaultWebPrefs = { '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 = @@ -394,6 +396,22 @@ async function getLocaleOverrideSetting(): Promise { return slowValue; } +const zoomFactorService = new ZoomFactorService({ + async getZoomFactorSetting() { + const item = await sql.sqlCall('getItemById', 'zoomFactor'); + if (typeof item?.value !== 'number') { + return null; + } + return item.value; + }, + async setZoomFactorSetting(zoomFactor) { + await sql.sqlCall('createOrUpdateItem', { + id: 'zoomFactor', + value: zoomFactor, + }); + }, +}); + let systemTrayService: SystemTrayService | undefined; const systemTraySettingCache = new SystemTraySettingCache( sql, @@ -523,7 +541,7 @@ async function handleUrl(rawTarget: string) { } } -function handleCommonWindowEvents( +async function handleCommonWindowEvents( window: BrowserWindow, titleBarOverlay: TitleBarOverlayOptions | false = false ) { @@ -557,54 +575,7 @@ function handleCommonWindowEvents( const focusInterval = setInterval(setWindowFocus, 10000); window.on('closed', () => clearInterval(focusInterval)); - // Works only for mainWindow and settings because they have `enablePreferredSizeMode` - let lastZoomFactor = window.webContents.getZoomFactor(); - const onZoomChanged = () => { - if ( - window.isDestroyed() || - !window.webContents || - window.webContents.isDestroyed() - ) { - return; - } - - const zoomFactor = window.webContents.getZoomFactor(); - if (lastZoomFactor === zoomFactor) { - return; - } - - lastZoomFactor = zoomFactor; - if (!mainWindow) { - return; - } - - if (window === mainWindow) { - drop( - settingsChannel?.invokeCallbackInMainWindow('persistZoomFactor', [ - zoomFactor, - ]) - ); - } else { - mainWindow.webContents.setZoomFactor(zoomFactor); - } - }; - window.on('show', () => { - // Install handler here after we init zoomFactor otherwise an initial - // preferred-size-changed event emits with an undesired zoomFactor. - window.webContents.on('preferred-size-changed', onZoomChanged); - }); - - // Workaround to apply zoomFactor because webPreferences does not handle it - // https://github.com/electron/electron/issues/10572 - // But main window emits ready-to-show before window.Events is available - // so set main window zoom in background.ts - if (window !== mainWindow) { - window.once('ready-to-show', async () => { - const zoomFactor = - (await settingsChannel?.getSettingFromMainWindow('zoomFactor')) ?? 1; - window.webContents.setZoomFactor(zoomFactor); - }); - } + await zoomFactorService.syncWindow(window); nativeThemeNotifier.addWindow(window); @@ -788,7 +759,6 @@ async function createWindow() { ), spellcheck: await getSpellCheckSetting(), backgroundThrottling: true, - enablePreferredSizeMode: true, disableBlinkFeatures: 'Accelerated2dCanvas,AcceleratedSmallCanvases', }, icon: windowIcon, @@ -917,7 +887,7 @@ async function createWindow() { mainWindow.webContents.openDevTools(); } - handleCommonWindowEvents(mainWindow, titleBarOverlay); + await handleCommonWindowEvents(mainWindow, titleBarOverlay); // App dock icon bounce bounce.init(mainWindow); @@ -1293,7 +1263,7 @@ async function showScreenShareWindow(sourceName: string) { screenShareWindow = new BrowserWindow(options); - handleCommonWindowEvents(screenShareWindow); + await handleCommonWindowEvents(screenShareWindow); screenShareWindow.on('closed', () => { screenShareWindow = undefined; @@ -1343,7 +1313,7 @@ async function showAbout() { aboutWindow = new BrowserWindow(options); - handleCommonWindowEvents(aboutWindow, titleBarOverlay); + await handleCommonWindowEvents(aboutWindow, titleBarOverlay); aboutWindow.on('closed', () => { aboutWindow = undefined; @@ -1389,13 +1359,12 @@ async function showSettingsWindow() { contextIsolation: true, preload: join(__dirname, '../bundles/settings/preload.js'), nativeWindowOpen: true, - enablePreferredSizeMode: true, }, }; settingsWindow = new BrowserWindow(options); - handleCommonWindowEvents(settingsWindow, titleBarOverlay); + await handleCommonWindowEvents(settingsWindow, titleBarOverlay); settingsWindow.on('closed', () => { settingsWindow = undefined; @@ -1490,7 +1459,7 @@ async function showDebugLogWindow() { debugLogWindow = new BrowserWindow(options); - handleCommonWindowEvents(debugLogWindow, titleBarOverlay); + await handleCommonWindowEvents(debugLogWindow, titleBarOverlay); debugLogWindow.on('closed', () => { debugLogWindow = undefined; @@ -1550,7 +1519,7 @@ function showPermissionsPopupWindow(forCalling: boolean, forCamera: boolean) { permissionsPopupWindow = new BrowserWindow(options); - handleCommonWindowEvents(permissionsPopupWindow); + await handleCommonWindowEvents(permissionsPopupWindow); permissionsPopupWindow.on('closed', () => { removeDarkOverlay(); @@ -2122,6 +2091,9 @@ function setupMenu(options?: Partial) { showKeyboardShortcuts, showSettings: showSettingsWindow, showWindow, + zoomIn, + zoomOut, + zoomReset, // overrides ...options, @@ -2815,16 +2787,6 @@ ipc.handle('executeMenuRole', async ({ sender }, untypedRole) => { sender.toggleDevTools(); break; - case 'resetZoom': - sender.setZoomLevel(0); - break; - case 'zoomIn': - sender.setZoomLevel(sender.getZoomLevel() + 1); - break; - case 'zoomOut': - sender.setZoomLevel(sender.getZoomLevel() - 1); - break; - case 'togglefullscreen': senderWindow?.setFullScreen(!senderWindow?.isFullScreen()); break; @@ -2862,6 +2824,18 @@ ipc.handle('getMenuOptions', async () => { }; }); +async function zoomIn() { + await zoomFactorService.zoomIn(); +} + +async function zoomOut() { + await zoomFactorService.zoomOut(); +} + +async function zoomReset() { + await zoomFactorService.zoomReset(); +} + ipc.handle('executeMenuAction', async (_event, action: MenuActionType) => { if (action === 'forceUpdate') { drop(forceUpdate()); @@ -2891,6 +2865,12 @@ ipc.handle('executeMenuAction', async (_event, action: MenuActionType) => { drop(showSettingsWindow()); } else if (action === 'showWindow') { showWindow(); + } else if (action === 'zoomIn') { + drop(zoomIn()); + } else if (action === 'zoomOut') { + drop(zoomOut()); + } else if (action === 'zoomReset') { + drop(zoomReset()); } else { throw missingCaseError(action); } @@ -2940,7 +2920,7 @@ async function showStickerCreatorWindow() { stickerCreatorWindow = new BrowserWindow(options); - handleCommonWindowEvents(stickerCreatorWindow); + await handleCommonWindowEvents(stickerCreatorWindow); stickerCreatorWindow.once('ready-to-show', () => { stickerCreatorWindow?.show(); diff --git a/app/menu.ts b/app/menu.ts index d63d1390ce5c..693254ba0c91 100644 --- a/app/menu.ts +++ b/app/menu.ts @@ -38,6 +38,9 @@ export const createTemplate = ( showKeyboardShortcuts, showSettings, openArtCreator, + zoomIn, + zoomOut, + zoomReset, } = options; const template: MenuListType = [ @@ -106,17 +109,19 @@ export const createTemplate = ( label: i18n('icu:mainMenuView'), submenu: [ { - role: 'resetZoom', + accelerator: 'CmdOrCtrl+0', label: i18n('icu:viewMenuResetZoom'), + click: zoomReset, }, { accelerator: 'CmdOrCtrl+=', - role: 'zoomIn', label: i18n('icu:viewMenuZoomIn'), + click: zoomIn, }, { - role: 'zoomOut', + accelerator: 'CmdOrCtrl+-', label: i18n('icu:viewMenuZoomOut'), + click: zoomOut, }, { type: 'separator', diff --git a/ts/background.ts b/ts/background.ts index c4e357e61127..bf9cb80daa08 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1,7 +1,6 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { webFrame } from 'electron'; import { isNumber, throttle, groupBy } from 'lodash'; import { render } from 'react-dom'; import { batch as batchDispatch } from 'react-redux'; @@ -819,14 +818,13 @@ export async function startApp(): Promise { }, }); - const zoomFactor = window.Events.getZoomFactor(); - webFrame.setZoomFactor(zoomFactor); + const zoomFactor = await window.Events.getZoomFactor(); document.body.style.setProperty('--zoom-factor', zoomFactor.toString()); - window.addEventListener('resize', () => { + window.Events.onZoomFactorChange(newZoomFactor => { document.body.style.setProperty( '--zoom-factor', - webFrame.getZoomFactor().toString() + newZoomFactor.toString() ); }); diff --git a/ts/components/TitleBarContainer.tsx b/ts/components/TitleBarContainer.tsx index 71938d23adcb..86971146877c 100644 --- a/ts/components/TitleBarContainer.tsx +++ b/ts/components/TitleBarContainer.tsx @@ -238,6 +238,9 @@ export function TitleBarContainer(props: PropsType): JSX.Element { showKeyboardShortcuts: () => executeMenuAction('showKeyboardShortcuts'), showSettings: () => executeMenuAction('showSettings'), showWindow: () => executeMenuAction('showWindow'), + zoomIn: () => executeMenuAction('zoomIn'), + zoomOut: () => executeMenuAction('zoomOut'), + zoomReset: () => executeMenuAction('zoomReset'), }, i18n ); diff --git a/ts/services/ZoomFactorService.ts b/ts/services/ZoomFactorService.ts new file mode 100644 index 000000000000..ff0734fae292 --- /dev/null +++ b/ts/services/ZoomFactorService.ts @@ -0,0 +1,123 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { BrowserWindow } from 'electron'; +import { ipcMain } from 'electron'; +import EventEmitter from 'events'; + +const DEFAULT_ZOOM_FACTOR = 1.0; + +// https://chromium.googlesource.com/chromium/src/+/938b37a6d2886bf8335fc7db792f1eb46c65b2ae/third_party/blink/common/page/page_zoom.cc +const ZOOM_LEVEL_MULTIPLIER_RATIO = 1.2; + +function zoomLevelToZoomFactor(zoomLevel: number): number { + return ZOOM_LEVEL_MULTIPLIER_RATIO ** zoomLevel; +} + +function zoomFactorToZoomLevel(zoomFactor: number) { + return Math.log(zoomFactor) / Math.log(ZOOM_LEVEL_MULTIPLIER_RATIO); +} + +function zoomFactorEquals(a: number, b: number): boolean { + return Math.abs(a - b) <= 0.001; +} + +type ZoomFactorServiceConfig = Readonly<{ + getZoomFactorSetting: () => Promise; + setZoomFactorSetting: (zoomFactor: number) => Promise; +}>; + +export class ZoomFactorService extends EventEmitter { + #config: ZoomFactorServiceConfig; + #cachedZoomFactor: number | null = null; + + constructor(config: ZoomFactorServiceConfig) { + super(); + this.#config = config; + ipcMain.handle('getZoomFactor', () => { + return this.getZoomFactor(); + }); + ipcMain.on('setZoomFactor', (_event, zoomFactor) => { + return this.setZoomFactor(zoomFactor); + }); + } + + async getZoomFactor(): Promise { + if (this.#cachedZoomFactor != null) { + return this.#cachedZoomFactor; + } + const zoomFactorSetting = await this.#config.getZoomFactorSetting(); + const zoomFactor = zoomFactorSetting ?? DEFAULT_ZOOM_FACTOR; + this.#cachedZoomFactor = zoomFactor; + return zoomFactor; + } + + async getZoomLevel(): Promise { + const zoomFactor = await this.getZoomFactor(); + return zoomFactorToZoomLevel(zoomFactor); + } + + async setZoomFactor(zoomFactor: number): Promise { + if ( + this.#cachedZoomFactor != null && + zoomFactorEquals(this.#cachedZoomFactor, zoomFactor) + ) { + return; + } + this.#cachedZoomFactor = zoomFactor; + await this.#config.setZoomFactorSetting(zoomFactor); + this.emit('zoomFactorChanged', zoomFactor); + } + + async setZoomLevel(zoomLevel: number): Promise { + const zoomFactor = zoomLevelToZoomFactor(zoomLevel); + await this.setZoomFactor(zoomFactor); + } + + async zoomIn(): Promise { + const zoomLevel = await this.getZoomLevel(); + await this.setZoomLevel(zoomLevel + 1); + } + + async zoomOut(): Promise { + const zoomLevel = await this.getZoomLevel(); + await this.setZoomLevel(zoomLevel - 1); + } + + async zoomReset(): Promise { + await this.setZoomLevel(0); + } + + // Call this after creating a new window before you show it + async syncWindow(window: BrowserWindow): Promise { + const onWindowChange = async () => { + const zoomFactor = window.webContents.getZoomFactor(); + await this.setZoomFactor(zoomFactor); + }; + + const onServiceChange = (zoomFactor: number) => { + window.webContents.setZoomFactor(zoomFactor); + window.webContents.send('zoomFactorChanged', zoomFactor); + }; + + const initialZoomFactor = await this.getZoomFactor(); + window.once('ready-to-show', () => { + // Workaround to apply zoomFactor because webPreferences does not handle it + // https://github.com/electron/electron/issues/10572 + window.webContents.setZoomFactor(initialZoomFactor); + }); + + window.once('show', async () => { + // Install handler here after we init zoomFactor otherwise an initial + // preferred-size-changed event emits with an undesired zoomFactor. + window.webContents.on('preferred-size-changed', onWindowChange); + window.webContents.on('zoom-changed', onWindowChange); + this.on('zoomFactorChanged', onServiceChange); + }); + + window.on('close', () => { + window.webContents.off('preferred-size-changed', onWindowChange); + window.webContents.off('zoom-changed', onWindowChange); + this.off('zoomFactorChanged', onServiceChange); + }); + } +} diff --git a/ts/test-node/app/menu_test.ts b/ts/test-node/app/menu_test.ts index 0d50b186efce..8e97503a4a4d 100644 --- a/ts/test-node/app/menu_test.ts +++ b/ts/test-node/app/menu_test.ts @@ -25,6 +25,9 @@ const showDebugLog = stub(); const showKeyboardShortcuts = stub(); const showSettings = stub(); const showWindow = stub(); +const zoomIn = stub(); +const zoomOut = stub(); +const zoomReset = stub(); const getExpectedEditMenu = ( includeSpeech: boolean @@ -58,9 +61,9 @@ const getExpectedEditMenu = ( const getExpectedViewMenu = (): MenuItemConstructorOptions => ({ label: '&View', submenu: [ - { label: 'Actual Size', role: 'resetZoom' }, - { accelerator: 'CmdOrCtrl+=', label: 'Zoom In', role: 'zoomIn' }, - { label: 'Zoom Out', role: 'zoomOut' }, + { accelerator: 'CmdOrCtrl+0', label: 'Actual Size', click: zoomReset }, + { accelerator: 'CmdOrCtrl+=', label: 'Zoom In', click: zoomIn }, + { accelerator: 'CmdOrCtrl+-', label: 'Zoom Out', click: zoomOut }, { type: 'separator' }, { label: 'Toggle Full Screen', role: 'togglefullscreen' }, { type: 'separator' }, @@ -227,6 +230,9 @@ describe('createTemplate', () => { showKeyboardShortcuts, showSettings, showWindow, + zoomIn, + zoomOut, + zoomReset, }; PLATFORMS.forEach(({ label, platform, expectedDefault }) => { diff --git a/ts/types/menu.ts b/ts/types/menu.ts index eda3ebb26d6f..dc627febe745 100644 --- a/ts/types/menu.ts +++ b/ts/types/menu.ts @@ -28,6 +28,9 @@ export type MenuActionsType = Readonly<{ showKeyboardShortcuts: () => unknown; showSettings: () => unknown; showWindow: () => unknown; + zoomIn: () => unknown; + zoomOut: () => unknown; + zoomReset: () => unknown; }>; export type MenuActionType = keyof MenuActionsType; diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index ad1178cad42d..3ef132dd353b 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -1,7 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { webFrame } from 'electron'; +import { ipcRenderer } from 'electron'; import type { AudioDevice } from '@signalapp/ringrtc'; import { noop } from 'lodash'; @@ -135,13 +135,16 @@ export type IPCEventsCallbacksType = { customColor?: { id: string; value: CustomColorType } ) => void; getDefaultConversationColor: () => DefaultConversationColorType; - persistZoomFactor: (factor: number) => Promise; }; type ValuesWithGetters = Omit< IPCEventsValuesType, + // Async + | 'zoomFactor' // Optional - 'mediaPermissions' | 'mediaCameraPermissions' | 'autoLaunch' + | 'mediaPermissions' + | 'mediaCameraPermissions' + | 'autoLaunch' >; type ValuesWithSetters = Omit< @@ -167,6 +170,11 @@ export type IPCEventSetterType = export type IPCEventsGettersType = { [Key in keyof ValuesWithGetters as IPCEventGetterType]: () => ValuesWithGetters[Key]; } & { + // Async + getZoomFactor: () => Promise; + // Events + onZoomFactorChange: (callback: (zoomFactor: ZoomFactorType) => void) => void; + // Optional getMediaPermissions?: () => Promise; getMediaCameraPermissions?: () => Promise; getAutoLaunch?: () => Promise; @@ -212,9 +220,16 @@ export function createIPCEvents( getDeviceName: () => window.textsecure.storage.user.getDeviceName(), - getZoomFactor: () => window.storage.get('zoomFactor', 1), - setZoomFactor: async (zoomFactor: ZoomFactorType) => { - webFrame.setZoomFactor(zoomFactor); + getZoomFactor: () => { + return ipcRenderer.invoke('getZoomFactor'); + }, + setZoomFactor: async zoomFactor => { + ipcRenderer.send('setZoomFactor', zoomFactor); + }, + onZoomFactorChange: callback => { + ipcRenderer.on('zoomFactorChanged', (_event, zoomFactor) => { + callback(zoomFactor); + }); }, setPhoneNumberDiscoverabilitySetting, @@ -615,9 +630,6 @@ export function createIPCEvents( getMediaPermissions: window.IPC.getMediaPermissions, getMediaCameraPermissions: window.IPC.getMediaCameraPermissions, - persistZoomFactor: zoomFactor => - window.storage.put('zoomFactor', zoomFactor), - ...overrideEvents, }; } diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index 72d85058ee6c..6368b53d97e4 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -14,7 +14,6 @@ installCallback('resetAllChatColors'); installCallback('resetDefaultChatColor'); installCallback('setGlobalDefaultConversationColor'); installCallback('getDefaultConversationColor'); -installCallback('persistZoomFactor'); // Getters only. These are set by the primary device installSetting('blockedCount', { diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts index cd301aaeff70..02598a04297b 100644 --- a/ts/windows/settings/preload.ts +++ b/ts/windows/settings/preload.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { contextBridge, ipcRenderer, webFrame } from 'electron'; +import { contextBridge, ipcRenderer } from 'electron'; import { MinimalSignalContext } from '../minimalContext'; import type { PropsPreloadType } from '../../components/Preferences'; @@ -423,16 +423,8 @@ async function renderPreferences() { onWhoCanSeeMeChange: attachRenderCallback( settingPhoneNumberSharing.setValue ), - - // Zoom factor change doesn't require immediate rerender since it will: - // 1. Update the zoom factor in the main window - // 2. Trigger `preferred-size-changed` in the main process - // 3. Finally result in `window.storage` update which will cause the - // rerender. - onZoomFactorChange: (value: number) => { - // Update Settings window zoom factor to match the selected value. - webFrame.setZoomFactor(value); - return settingZoomFactor.setValue(value); + onZoomFactorChange: (zoomFactorValue: number) => { + ipcRenderer.send('setZoomFactor', zoomFactorValue); }, hasCustomTitleBar: MinimalSignalContext.OS.hasCustomTitleBar(), @@ -442,7 +434,8 @@ async function renderPreferences() { renderInBrowser(props); } -ipcRenderer.on('preferences-changed', () => renderPreferences()); +ipcRenderer.on('preferences-changed', renderPreferences); +ipcRenderer.on('zoomFactorChanged', renderPreferences); const Signal = { SettingsWindowProps: {