From 4494f46408fbc7d26b865515e9a55914500d633f Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 06:23:02 +0530 Subject: [PATCH 01/14] feat: implement root window management, server views, and custom CSS handling for the UI --- src/ui/main/customCssManager.ts | 119 ++++++++++++++++++++++++++++++++ src/ui/main/rootWindow.ts | 3 + src/ui/main/serverView/index.ts | 2 + 3 files changed, 124 insertions(+) create mode 100644 src/ui/main/customCssManager.ts diff --git a/src/ui/main/customCssManager.ts b/src/ui/main/customCssManager.ts new file mode 100644 index 0000000000..eef2dbbbaa --- /dev/null +++ b/src/ui/main/customCssManager.ts @@ -0,0 +1,119 @@ +import fs from 'fs'; +import path from 'path'; + +import type { WebContents } from 'electron'; +import { app } from 'electron'; + +type CssTarget = 'shell' | 'workspace'; + +const cssCache: Record = { + shell: '', + workspace: '', +}; + +const registry: Record> = { + shell: new Set(), + workspace: new Set(), +}; + +const activeKeys = new WeakMap(); + +let managerInitialized = false; + +const applyCssToWebContents = async ( + webContents: WebContents, + target: CssTarget +) => { + if (webContents.isDestroyed()) { + registry[target].delete(webContents); + return; + } + + const currentKey = activeKeys.get(webContents); + if (currentKey) { + try { + await webContents.removeInsertedCSS(currentKey); + } catch (err) { + // Ignore errors if css is already cleared by navigation + } + activeKeys.delete(webContents); + } + + const css = cssCache[target]; + if (css && css.trim().length > 0) { + try { + const key = await webContents.insertCSS(css); + activeKeys.set(webContents, key); + } catch (err) { + console.error(`[CustomCSS] Failed to insert ${target} CSS:`, err); + } + } +}; + +const applyCssToAll = (target: CssTarget) => { + for (const wc of registry[target]) { + applyCssToWebContents(wc, target); + } +}; + +const reloadCache = async (target: CssTarget, filename: string) => { + const filePath = path.join(app.getPath('userData'), filename); + try { + cssCache[target] = await fs.promises.readFile(filePath, 'utf8'); + } catch (err) { + cssCache[target] = ''; // file removed or unreadable + } + applyCssToAll(target); +}; + +const initManager = () => { + if (managerInitialized) return; + managerInitialized = true; + + const userDataPath = app.getPath('userData'); + + // Initial load + reloadCache('shell', 'custom-shell.css'); + reloadCache('workspace', 'custom.css'); + + // Watch directory for changes to those files + try { + let debounceTimer: NodeJS.Timeout | null = null; + fs.watch(userDataPath, (_eventType, filename) => { + if (filename === 'custom-shell.css' || filename === 'custom.css') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + if (filename === 'custom-shell.css') + reloadCache('shell', 'custom-shell.css'); + if (filename === 'custom.css') reloadCache('workspace', 'custom.css'); + }, 100); + } + }); + } catch (err) { + console.error('[CustomCSS] Failed to watch userData directory:', err); + } +}; + +const setupCssInjection = (webContents: WebContents, target: CssTarget) => { + initManager(); + + registry[target].add(webContents); + + // Apply immediately in case it's already loaded + applyCssToWebContents(webContents, target); + + // Re-apply on navigations/reloads + webContents.on('did-finish-load', () => { + applyCssToWebContents(webContents, target); + }); + + // Cleanup on destroy + webContents.on('destroyed', () => { + registry[target].delete(webContents); + }); +}; + +export const setupShellCss = (webContents: WebContents) => + setupCssInjection(webContents, 'shell'); +export const setupWorkspaceCss = (webContents: WebContents) => + setupCssInjection(webContents, 'workspace'); diff --git a/src/ui/main/rootWindow.ts b/src/ui/main/rootWindow.ts index c0ec9955c0..f0ca51007b 100644 --- a/src/ui/main/rootWindow.ts +++ b/src/ui/main/rootWindow.ts @@ -36,6 +36,7 @@ import { } from '../actions'; import type { WindowState } from '../common'; import { selectGlobalBadge, selectGlobalBadgeCount } from '../selectors'; +import { setupShellCss } from './customCssManager'; import { debounce } from './debounce'; import { getTrayIconPath } from './icons'; @@ -137,6 +138,8 @@ export const createRootWindow = (): void => { event.preventDefault(); }); + setupShellCss(_rootWindow.webContents); + tempWindow.destroy(); }; diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index 9d34cc0f87..ec12881123 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -51,6 +51,7 @@ import { WEBVIEW_AUDIO_MUTED_CHANGED, WEBVIEW_MEDIA_CAPTURE_CHANGED, } from '../../actions'; +import { setupWorkspaceCss } from '../customCssManager'; import { handleMediaPermissionRequest } from '../mediaPermissions'; import { getRootWindow } from '../rootWindow'; import { @@ -298,6 +299,7 @@ const initializeServerWebContentsAfterAttach = ( ): void => { webContentsByServerUrl.set(serverUrl, guestWebContents); attachBootWatchdog(serverUrl, guestWebContents); + setupWorkspaceCss(guestWebContents); let audibleHoldTimer: NodeJS.Timeout | undefined; From 7633c9a25124f9c22d44e19556fd3145b7dc40c2 Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 06:38:08 +0530 Subject: [PATCH 02/14] feat: add custom CSS manager to inject and watch user styles for shell and workspace --- src/ui/main/customCssManager.ts | 71 +++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/src/ui/main/customCssManager.ts b/src/ui/main/customCssManager.ts index eef2dbbbaa..82f8829c71 100644 --- a/src/ui/main/customCssManager.ts +++ b/src/ui/main/customCssManager.ts @@ -20,34 +20,39 @@ const activeKeys = new WeakMap(); let managerInitialized = false; -const applyCssToWebContents = async ( - webContents: WebContents, - target: CssTarget -) => { - if (webContents.isDestroyed()) { - registry[target].delete(webContents); - return; - } +const applyPromises = new WeakMap>(); + +const applyCssToWebContents = (webContents: WebContents, target: CssTarget) => { + const currentPromise = applyPromises.get(webContents) || Promise.resolve(); + const newPromise = currentPromise.then(async () => { + if (webContents.isDestroyed()) { + registry[target].delete(webContents); + return; + } - const currentKey = activeKeys.get(webContents); - if (currentKey) { - try { - await webContents.removeInsertedCSS(currentKey); - } catch (err) { - // Ignore errors if css is already cleared by navigation + const currentKey = activeKeys.get(webContents); + if (currentKey) { + try { + await webContents.removeInsertedCSS(currentKey); + } catch (err) { + // Ignore errors if css is already cleared by navigation + } + activeKeys.delete(webContents); } - activeKeys.delete(webContents); - } - const css = cssCache[target]; - if (css && css.trim().length > 0) { - try { - const key = await webContents.insertCSS(css); - activeKeys.set(webContents, key); - } catch (err) { - console.error(`[CustomCSS] Failed to insert ${target} CSS:`, err); + const css = cssCache[target]; + if (css && css.trim().length > 0) { + try { + const key = await webContents.insertCSS(css); + activeKeys.set(webContents, key); + } catch (err) { + console.error(`[CustomCSS] Failed to insert ${target} CSS:`, err); + } } - } + }); + + applyPromises.set(webContents, newPromise); + return newPromise; }; const applyCssToAll = (target: CssTarget) => { @@ -78,14 +83,18 @@ const initManager = () => { // Watch directory for changes to those files try { - let debounceTimer: NodeJS.Timeout | null = null; + let shellTimer: NodeJS.Timeout | null = null; + let workspaceTimer: NodeJS.Timeout | null = null; fs.watch(userDataPath, (_eventType, filename) => { - if (filename === 'custom-shell.css' || filename === 'custom.css') { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - if (filename === 'custom-shell.css') - reloadCache('shell', 'custom-shell.css'); - if (filename === 'custom.css') reloadCache('workspace', 'custom.css'); + if (filename === 'custom-shell.css') { + if (shellTimer) clearTimeout(shellTimer); + shellTimer = setTimeout(() => { + reloadCache('shell', 'custom-shell.css'); + }, 100); + } else if (filename === 'custom.css') { + if (workspaceTimer) clearTimeout(workspaceTimer); + workspaceTimer = setTimeout(() => { + reloadCache('workspace', 'custom.css'); }, 100); } }); From 6c059de79d3311fa3fd6fcef95de5805afc732bb Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 06:47:50 +0530 Subject: [PATCH 03/14] fix: address CodeRabbit review feedback for custom CSS manager --- src/ui/main/customCssManager.ts | 17 +++++++++++++++-- src/ui/main/serverView/index.ts | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/ui/main/customCssManager.ts b/src/ui/main/customCssManager.ts index 82f8829c71..20e73fc0c1 100644 --- a/src/ui/main/customCssManager.ts +++ b/src/ui/main/customCssManager.ts @@ -61,13 +61,26 @@ const applyCssToAll = (target: CssTarget) => { } }; +const generationCounter: Record = { + shell: 0, + workspace: 0, +}; + const reloadCache = async (target: CssTarget, filename: string) => { + const currentGen = ++generationCounter[target]; const filePath = path.join(app.getPath('userData'), filename); + let content = ''; try { - cssCache[target] = await fs.promises.readFile(filePath, 'utf8'); + content = await fs.promises.readFile(filePath, 'utf8'); } catch (err) { - cssCache[target] = ''; // file removed or unreadable + content = ''; // file removed or unreadable } + + if (generationCounter[target] !== currentGen) { + return; + } + + cssCache[target] = content; applyCssToAll(target); }; diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index ec12881123..8535c88aa4 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -299,7 +299,6 @@ const initializeServerWebContentsAfterAttach = ( ): void => { webContentsByServerUrl.set(serverUrl, guestWebContents); attachBootWatchdog(serverUrl, guestWebContents); - setupWorkspaceCss(guestWebContents); let audibleHoldTimer: NodeJS.Timeout | undefined; @@ -692,6 +691,7 @@ export const attachGuestWebContentsEvents = async (): Promise => { if (process.env.NODE_ENV === 'development') { setupPreloadReload(webContents); } + setupWorkspaceCss(webContents); webContents.setWindowOpenHandler(({ url, frameName, disposition }) => { if ( From 6df4d58eca620b6ce75d31d983ec6cdea453292b Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 07:14:49 +0530 Subject: [PATCH 04/14] fix: exclude video-call webviews from workspace CSS injection --- src/ui/main/serverView/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index 8535c88aa4..4d6042373e 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -656,6 +656,8 @@ const initializeServerWebContentsAfterAttach = ( export const attachGuestWebContentsEvents = async (): Promise => { const rootWindow = await getRootWindow(); + let nextWebviewIsVideoCall = false; + const handleWillAttachWebview = ( _event: Event, webPreferences: WebPreferences, @@ -666,6 +668,7 @@ export const attachGuestWebContentsEvents = async (): Promise => { _params.partition, _params.frameName ); + nextWebviewIsVideoCall = isVideoCall; const preloadPath = resolvePreloadPath(isVideoCall); if (preloadPath) { webPreferences.preload = preloadPath; @@ -691,7 +694,10 @@ export const attachGuestWebContentsEvents = async (): Promise => { if (process.env.NODE_ENV === 'development') { setupPreloadReload(webContents); } - setupWorkspaceCss(webContents); + + if (!nextWebviewIsVideoCall) { + setupWorkspaceCss(webContents); + } webContents.setWindowOpenHandler(({ url, frameName, disposition }) => { if ( From 572b4e8c5b510f681da4409b45d4af561de9ddff Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 08:09:21 +0530 Subject: [PATCH 05/14] feat: Phase 1 - Context isolation for LogViewerWindow --- rollup.config.mjs | 31 +++++++++++ .../__tests__/logViewerWindow.spec.tsx | 16 +++--- src/logViewerWindow/ipc.ts | 6 ++- src/logViewerWindow/logViewerWindow.tsx | 33 ++++++++---- src/logViewerWindow/preload.ts | 51 +++++++++++++++++++ src/logging/preload.ts | 20 ++++++-- src/ui/windowChrome/useTransparency.ts | 23 +++++++-- 7 files changed, 151 insertions(+), 29 deletions(-) create mode 100644 src/logViewerWindow/preload.ts diff --git a/rollup.config.mjs b/rollup.config.mjs index f52eaebe5f..45e1631ed9 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -526,6 +526,37 @@ export default [ }, ], }, + { + external: makeExternal(['@bugsnag/js']), + input: 'src/logViewerWindow/preload.ts', + preserveEntrySignatures: 'strict', + plugins: [ + json(), + replace({ + 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), + 'preventAssignment': true, + }), + babel({ + babelHelpers: 'bundled', + extensions, + }), + nodeResolve({ + browser: true, + extensions, + }), + commonjs(), + run(), + ], + output: [ + { + dir: 'app/logViewerWindow', + entryFileNames: 'preload.js', + format: 'cjs', + sourcemap: 'inline', + interop: 'auto', + }, + ], + }, { external: makeExternal([ '@bugsnag/js', diff --git a/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx b/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx index 5b4693fc1e..df37e5edf2 100644 --- a/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx +++ b/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx @@ -8,13 +8,15 @@ const invoke = jest.fn(); const on = jest.fn(); const removeListener = jest.fn(); -jest.mock('electron', () => ({ - ipcRenderer: { - invoke: (...args: any[]) => invoke(...args), - on: (...args: any[]) => on(...args), - removeListener: (...args: any[]) => removeListener(...args), - }, -})); +beforeAll(() => { + (window as any).RocketChatDesktop = { + logViewer: { + invoke: (...args: any[]) => invoke(...args), + on: (...args: any[]) => on(...args), + removeListener: (...args: any[]) => removeListener(...args), + }, + }; +}); jest.mock('react-i18next', () => ({ useTranslation: () => ({ diff --git a/src/logViewerWindow/ipc.ts b/src/logViewerWindow/ipc.ts index c7c0eaec00..77b99521ff 100644 --- a/src/logViewerWindow/ipc.ts +++ b/src/logViewerWindow/ipc.ts @@ -259,8 +259,10 @@ const buildLogViewerWindow = async (focusOnShow: boolean): Promise => { } : {}), webPreferences: { - nodeIntegration: true, - contextIsolation: false, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + preload: path.join(app.getAppPath(), 'app/logViewerWindow/preload.js'), }, show: false, }); diff --git a/src/logViewerWindow/logViewerWindow.tsx b/src/logViewerWindow/logViewerWindow.tsx index ed30e4c5cf..ed5f89589c 100644 --- a/src/logViewerWindow/logViewerWindow.tsx +++ b/src/logViewerWindow/logViewerWindow.tsx @@ -16,7 +16,20 @@ import { useLocalStorage, useDebouncedValue, } from '@rocket.chat/fuselage-hooks'; -import { ipcRenderer } from 'electron'; +// import { ipcRenderer } from 'electron'; + +declare global { + interface Window { + RocketChatDesktop: { + logViewer: { + invoke: (channel: string, ...args: any[]) => Promise; + on: (channel: string, listener: (...args: any[]) => void) => () => void; + sendSync: (channel: string, ...args: any[]) => any; + }; + }; + } +} + import type { ChangeEvent } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -266,7 +279,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { setIsLoading(true); try { setLoadError(null); - const response = (await ipcRenderer.invoke( + const response = (await window.RocketChatDesktop.logViewer.invoke( 'log-viewer-window/read-logs', { limit: 'all', @@ -614,7 +627,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { useEffect(() => { const fetchMapping = async () => { try { - const response = (await ipcRenderer.invoke( + const response = (await window.RocketChatDesktop.logViewer.invoke( 'log-viewer-window/get-server-mapping' )) as { success: boolean; mapping: Record }; if (response?.success) { @@ -647,7 +660,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { if (!isStreaming || !currentLogFile.isDefaultLog) return; try { - const statResponse = (await ipcRenderer.invoke( + const statResponse = (await window.RocketChatDesktop.logViewer.invoke( 'log-viewer-window/stat-log', { filePath: undefined } )) as { success: boolean; lastModifiedTime?: number; size?: number }; @@ -674,7 +687,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { } // Incremental read: only fetch new bytes appended since last read - const tailResponse = (await ipcRenderer.invoke( + const tailResponse = (await window.RocketChatDesktop.logViewer.invoke( 'log-viewer-window/read-logs-tail', { fromByte: previousSize } )) as ReadLogsTailResponse; @@ -833,7 +846,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { const handleOpenLogFile = useCallback(async () => { try { - const response = (await ipcRenderer.invoke( + const response = (await window.RocketChatDesktop.logViewer.invoke( 'log-viewer-window/select-log-file' )) as SelectFileResponse; if (response?.success && response.filePath) { @@ -870,7 +883,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { const handleRevealLogFile = useCallback(async () => { try { - const response = (await ipcRenderer.invoke( + const response = (await window.RocketChatDesktop.logViewer.invoke( 'log-viewer-window/reveal-log-file', { filePath: currentLogFile.isDefaultLog @@ -895,12 +908,12 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { return; } try { - const confirmed = await ipcRenderer.invoke( + const confirmed = await window.RocketChatDesktop.logViewer.invoke( 'log-viewer-window/confirm-clear-logs' ); if (!confirmed) return; - const response = (await ipcRenderer.invoke( + const response = (await window.RocketChatDesktop.logViewer.invoke( 'log-viewer-window/clear-logs' )) as ClearLogsResponse; if (response?.success) { @@ -931,7 +944,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { .toISOString() .slice(0, 19) .replace(/:/g, '-'); - const response = (await ipcRenderer.invoke( + const response = (await window.RocketChatDesktop.logViewer.invoke( 'log-viewer-window/save-logs', { content: logText, diff --git a/src/logViewerWindow/preload.ts b/src/logViewerWindow/preload.ts new file mode 100644 index 0000000000..a242e9092a --- /dev/null +++ b/src/logViewerWindow/preload.ts @@ -0,0 +1,51 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +const ALLOWED_INVOKE_CHANNELS = new Set([ + 'log-viewer-window/select-log-file', + 'log-viewer-window/read-logs', + 'log-viewer-window/stat-log', + 'log-viewer-window/read-logs-tail', + 'log-viewer-window/reveal-log-file', + 'log-viewer-window/confirm-clear-logs', + 'log-viewer-window/clear-logs', + 'log-viewer-window/save-logs', + 'log-viewer-window/get-server-mapping', +]); + +const ALLOWED_ON_CHANNELS = new Set([ + 'transparency-enabled', // This is the TRANSPARENCY_CHANNEL string from constants +]); + +const logViewer = { + invoke: async (channel: string, ...args: any[]) => { + if (ALLOWED_INVOKE_CHANNELS.has(channel)) { + // Safe clone arguments to strip prototype pollution + const safeArgs = args.map((arg) => (arg !== undefined ? JSON.parse(JSON.stringify(arg)) : undefined)); + return ipcRenderer.invoke(channel, ...safeArgs); + } + console.warn(`Blocked unauthorized outgoing IPC invoke: ${channel}`); + throw new Error(`Unauthorized IPC invoke: ${channel}`); + }, + on: (channel: string, listener: (...args: any[]) => void) => { + if (ALLOWED_ON_CHANNELS.has(channel)) { + const subscription = (_event: Electron.IpcRendererEvent, ...args: any[]) => listener(...args); + ipcRenderer.on(channel, subscription); + return () => ipcRenderer.removeListener(channel, subscription); + } + console.warn(`Blocked unauthorized incoming IPC listener: ${channel}`); + return () => {}; + }, + sendSync: (channel: string, ...args: any[]) => { + // Specifically allow get-server-tag for the logging preload + if (channel === 'log-viewer-window/get-server-tag') { + const safeArgs = args.map((arg) => (arg !== undefined ? JSON.parse(JSON.stringify(arg)) : undefined)); + return ipcRenderer.sendSync(channel, ...safeArgs); + } + console.warn(`Blocked unauthorized outgoing IPC sendSync: ${channel}`); + return null; + } +}; + +contextBridge.exposeInMainWorld('RocketChatDesktop', { + logViewer, +}); diff --git a/src/logging/preload.ts b/src/logging/preload.ts index c98bcae534..cf26da5c30 100644 --- a/src/logging/preload.ts +++ b/src/logging/preload.ts @@ -1,4 +1,4 @@ -import { ipcRenderer } from 'electron'; +// import { ipcRenderer } from 'electron'; import log from 'electron-log/renderer'; import { getProcessContext, getComponentContext } from './context'; @@ -12,10 +12,20 @@ try { let serverTag = ''; if (processContext === 'renderer:webview' && typeof window !== 'undefined') { try { - const result = ipcRenderer.sendSync( - 'log-viewer-window/get-server-tag', - window.location?.origin || '' - ); + let result; + const bridge = (window as any).RocketChatDesktop?.logViewer; + if (bridge) { + result = bridge.sendSync( + 'log-viewer-window/get-server-tag', + window.location?.origin || '' + ); + } else { + const { ipcRenderer } = require('electron'); + result = ipcRenderer.sendSync( + 'log-viewer-window/get-server-tag', + window.location?.origin || '' + ); + } if (result) { serverTag = ` [${result}]`; } diff --git a/src/ui/windowChrome/useTransparency.ts b/src/ui/windowChrome/useTransparency.ts index dd3176c431..fe96caaa6b 100644 --- a/src/ui/windowChrome/useTransparency.ts +++ b/src/ui/windowChrome/useTransparency.ts @@ -1,4 +1,3 @@ -import { ipcRenderer } from 'electron'; import { useEffect, useState } from 'react'; import { isDarwin, readInitialTransparency } from './appearance'; @@ -23,10 +22,24 @@ export const useTransparency = (channel: string): boolean => { setIsEnabled(Boolean(enabled)); }; - ipcRenderer.on(channel, handleChange); - return () => { - ipcRenderer.off(channel, handleChange); - }; + // Use contextBridge if available (nodeIntegration: false) + const bridge = (window as any).RocketChatDesktop?.logViewer; + if (bridge) { + const unsubscribe = bridge.on(channel, handleChange); + return unsubscribe; + } + + // Fallback for non-isolated windows (nodeIntegration: true) + try { + const { ipcRenderer } = require('electron'); + ipcRenderer.on(channel, handleChange); + return () => { + ipcRenderer.off(channel, handleChange); + }; + } catch (error) { + console.warn('Failed to subscribe to transparency changes:', error); + return () => {}; + } }, [channel]); // Vibrancy only reads as a material on macOS; elsewhere the window is opaque From 96e88e9859d1cadb24835a3cc335e6583231d645 Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 08:14:14 +0530 Subject: [PATCH 06/14] feat: Phase 2 - Refactor IPC in preload with whitelist and sanitization --- src/ipc/sanitize.ts | 101 ++++++++++++++++++++++++++++++++++++++++++++ src/preload.ts | 2 + 2 files changed, 103 insertions(+) create mode 100644 src/ipc/sanitize.ts diff --git a/src/ipc/sanitize.ts b/src/ipc/sanitize.ts new file mode 100644 index 0000000000..06b1a0c6cb --- /dev/null +++ b/src/ipc/sanitize.ts @@ -0,0 +1,101 @@ +import { ipcRenderer } from 'electron'; + +const sanitize = (args: any[]) => JSON.parse(JSON.stringify(args)); + +// Define allowed channels for the webview preload environment. +// This prevents the untrusted server view from exploiting the contextBridge +// to send unauthorized IPC messages to the main process or other windows. +const ALLOWED_CHANNELS = new Set([ + 'redux/get-initial-state', + 'redux/action-dispatched', + 'servers/fetch-info', + 'notifications/fetch-icon', + 'power-monitor/get-system-idle-state', + 'certificatesManager/remove', + 'server-view/get-url', + 'ui-preview/apply', + 'ui-preview/restore', + 'ui-preview/add', + 'ui-preview/refresh', + 'server-view/ready', + 'server-view/open-url-on-browser', + 'server-view/take-pending-conference', + 'video-call-window/open-window', + 'video-call-window/open-url', + 'video-call-window/open-in-main-window', + 'video-call-window/open-screen-picker', + 'video-call-window/screen-sharing-source-responded', + 'video-call-window/screen-recording-is-permission-granted', + 'video-call-window/close-requested', + 'video-call-window/open-webview-dev-tools', + 'video-call-window/handshake', + 'video-call-window/renderer-ready', + 'video-call-window/request-url', + 'video-call-window/url-received', + 'video-call-window/webview-created', + 'video-call-window/webview-loading', + 'video-call-window/webview-ready', + 'video-call-window/webview-failed', + 'video-call-window/get-language', + 'video-call-window/prewarm-capturer-cache', + 'video-call-window/media-capture-changed', + 'jitsi-desktop-capturer-get-sources', + 'desktop-capturer-get-sources', + 'outlook-calendar/get-events', + 'outlook-calendar/set-exchange-url', + 'outlook-calendar/has-credentials', + 'outlook-calendar/clear-credentials', + 'outlook-calendar/set-user-token', + 'browser/open-url', + 'document-viewer/open-window', + 'document-viewer/fetch-content', + 'secondary-window/minimize', + 'secondary-window/toggle-maximize', + 'secondary-window/close', + 'secondary-window/is-maximized', + 'screen-picker/open', + 'screen-picker/source-responded', + 'screen-picker/screen-recording-is-permission-granted', + 'screen-picker/open-url', + 'telephony/get-diagnostics', + 'presence/change-requested', + 'navigate-to-route', + 'log-viewer-window/get-server-tag' +]); + +const originalSend = ipcRenderer.send.bind(ipcRenderer); +const originalInvoke = ipcRenderer.invoke.bind(ipcRenderer); +const originalOn = ipcRenderer.on.bind(ipcRenderer); +const originalSendSync = ipcRenderer.sendSync.bind(ipcRenderer); + +ipcRenderer.send = (channel, ...args) => { + if (!ALLOWED_CHANNELS.has(channel) && !channel.includes('@')) { + console.warn(`Blocked unauthorized IPC send channel: ${channel}`); + return; + } + originalSend(channel, ...sanitize(args)); +}; + +ipcRenderer.invoke = (channel, ...args) => { + if (!ALLOWED_CHANNELS.has(channel)) { + console.warn(`Blocked unauthorized IPC invoke channel: ${channel}`); + return Promise.reject(new Error('Unauthorized IPC')); + } + return originalInvoke(channel, ...sanitize(args)); +}; + +ipcRenderer.sendSync = (channel, ...args) => { + if (!ALLOWED_CHANNELS.has(channel)) { + console.warn(`Blocked unauthorized IPC sendSync channel: ${channel}`); + return; + } + return originalSendSync(channel, ...sanitize(args)); +}; + +ipcRenderer.on = (channel, listener) => { + if (!ALLOWED_CHANNELS.has(channel) && !channel.includes('@')) { + console.warn(`Blocked unauthorized IPC on channel: ${channel}`); + return ipcRenderer; + } + return originalOn(channel, listener); +}; diff --git a/src/preload.ts b/src/preload.ts index ef6e6f8757..2fac58f617 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,3 +1,5 @@ +import './ipc/sanitize'; + import { contextBridge, webFrame } from 'electron'; import { invoke } from './ipc/renderer'; From 3eb28b9879dee47982d74a6af3a6dd712e236bf2 Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 08:17:27 +0530 Subject: [PATCH 07/14] feat: Phases 3 & 4 - Cryptographic Update Integrity and Power Efficiency --- src/injected.ts | 12 ++++++-- src/main.ts | 2 ++ src/powerMonitor/main.ts | 19 +++++++++++++ src/ui/windowChrome/useTransparency.ts | 1 + src/updates/main.ts | 39 +++++++++++++++++++++++++- 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 src/powerMonitor/main.ts diff --git a/src/injected.ts b/src/injected.ts index 8b52337996..b72bf17eff 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -1050,10 +1050,16 @@ const start = async () => { } }; - // Call setupReactiveFeatures immediately and then periodically check for new modules + // Call setupReactiveFeatures immediately and then dynamically detect app loads setupReactiveFeatures(); - setInterval(setupReactiveFeatures, 1000); // Check every second for newly loaded modules - + const observer = new MutationObserver(() => { + setupReactiveFeatures(); + }); + + const targetNode = document.querySelector('[data-reactroot]') || document.getElementById('react-root') || document.body; + if (targetNode) { + observer.observe(targetNode, { childList: true, subtree: true }); + } console.log('[Rocket.Chat Desktop] Injected'); }; diff --git a/src/main.ts b/src/main.ts index 8c2694e2ac..d6d8578573 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,6 +43,7 @@ import { stopOutlookCalendarSync, } from './outlookCalendar/ipc'; import { setupOutlookLogger } from './outlookCalendar/logger'; +import { setupPowerMonitor } from './powerMonitor/main'; import { handleDesktopCapturerGetSources } from './screenSharing/desktopCapturerCache'; import { setupScreenSharing } from './screenSharing/main'; import { startServerViewScreenSharingHandler } from './screenSharing/serverViewScreenSharing'; @@ -155,6 +156,7 @@ const start = async (): Promise => { setupApp(); setupMainErrorHandling(); + setupPowerMonitor(); createRootWindow(); startOutlookCalendarUrlHandler(); diff --git a/src/powerMonitor/main.ts b/src/powerMonitor/main.ts new file mode 100644 index 0000000000..2a4a7013d9 --- /dev/null +++ b/src/powerMonitor/main.ts @@ -0,0 +1,19 @@ +import { powerMonitor, webContents } from 'electron'; + +export const setupPowerMonitor = (): void => { + const throttleFrameRates = (fps: number) => { + webContents.getAllWebContents().forEach((wc) => { + try { + wc.setFrameRate(fps); + } catch (error) { + // ignore destroyed webcontents + } + }); + }; + + powerMonitor.on('suspend', () => throttleFrameRates(10)); + powerMonitor.on('lock-screen', () => throttleFrameRates(10)); + + powerMonitor.on('resume', () => throttleFrameRates(60)); + powerMonitor.on('unlock-screen', () => throttleFrameRates(60)); +}; diff --git a/src/ui/windowChrome/useTransparency.ts b/src/ui/windowChrome/useTransparency.ts index fe96caaa6b..15101f3d92 100644 --- a/src/ui/windowChrome/useTransparency.ts +++ b/src/ui/windowChrome/useTransparency.ts @@ -31,6 +31,7 @@ export const useTransparency = (channel: string): boolean => { // Fallback for non-isolated windows (nodeIntegration: true) try { + // eslint-disable-next-line @typescript-eslint/no-var-requires const { ipcRenderer } = require('electron'); ipcRenderer.on(channel, handleChange); return () => { diff --git a/src/updates/main.ts b/src/updates/main.ts index 7ec9fcc377..9723d3109c 100644 --- a/src/updates/main.ts +++ b/src/updates/main.ts @@ -1,5 +1,7 @@ +import { execFile } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { promisify } from 'util'; import { app } from 'electron'; import { autoUpdater } from 'electron-updater'; @@ -640,7 +642,42 @@ export const setupUpdates = async (): Promise => { }); }); - autoUpdater.addListener('update-downloaded', async () => { + autoUpdater.addListener('update-downloaded', async (info: any) => { + const downloadedFile = info?.downloadedFile; + if (downloadedFile) { + try { + const execFileAsync = promisify(execFile); + + if (process.platform === 'win32') { + const { stdout } = await execFileAsync('powershell.exe', [ + '-NoProfile', + '-Command', + `Get-AuthenticodeSignature "${downloadedFile}" | Select-Object -ExpandProperty Status`, + ]); + if (!stdout.includes('Valid')) { + throw new Error( + `Signature verification failed on Windows: ${stdout}` + ); + } + } else if (process.platform === 'darwin') { + await execFileAsync('codesign', ['-v', downloadedFile]); + } + } catch (err: any) { + console.error('Update integrity check failed, discarding update:', err); + try { + fs.unlinkSync(downloadedFile); + } catch (e) { + // ignore cleanup errors + } + dispatchUpdateError( + new Error( + 'Update signature verification failed. The update has been discarded for your safety.' + ) + ); + return; + } + } + dispatch({ type: UPDATES_UPDATE_DOWNLOADED }); // Downloads started from the titlebar label surface the restart action in From a3e48390c13bd4e6ff42a6c10501f2b5be131295 Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 08:18:34 +0530 Subject: [PATCH 08/14] fix: Rename setupPowerMonitor to setupFrameRateThrottling --- src/main.ts | 4 ++-- src/powerMonitor/main.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index d6d8578573..dd08da4b71 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,7 +43,7 @@ import { stopOutlookCalendarSync, } from './outlookCalendar/ipc'; import { setupOutlookLogger } from './outlookCalendar/logger'; -import { setupPowerMonitor } from './powerMonitor/main'; +import { setupFrameRateThrottling } from './powerMonitor/main'; import { handleDesktopCapturerGetSources } from './screenSharing/desktopCapturerCache'; import { setupScreenSharing } from './screenSharing/main'; import { startServerViewScreenSharingHandler } from './screenSharing/serverViewScreenSharing'; @@ -156,7 +156,7 @@ const start = async (): Promise => { setupApp(); setupMainErrorHandling(); - setupPowerMonitor(); + setupFrameRateThrottling(); createRootWindow(); startOutlookCalendarUrlHandler(); diff --git a/src/powerMonitor/main.ts b/src/powerMonitor/main.ts index 2a4a7013d9..4b266243f1 100644 --- a/src/powerMonitor/main.ts +++ b/src/powerMonitor/main.ts @@ -1,6 +1,6 @@ import { powerMonitor, webContents } from 'electron'; -export const setupPowerMonitor = (): void => { +export const setupFrameRateThrottling = (): void => { const throttleFrameRates = (fps: number) => { webContents.getAllWebContents().forEach((wc) => { try { From 3939104e26e51c5e369cd373f7c87550af7a4d27 Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 08:21:02 +0530 Subject: [PATCH 09/14] chore: Phase 3 - Merge scripts READMEs into docs/DEVELOPMENT.md --- scripts/README.md => docs/DEVELOPMENT.md | 65 ++++++++++++++++++++++-- scripts/msi-test/README.md | 57 --------------------- 2 files changed, 62 insertions(+), 60 deletions(-) rename scripts/README.md => docs/DEVELOPMENT.md (63%) delete mode 100644 scripts/msi-test/README.md diff --git a/scripts/README.md b/docs/DEVELOPMENT.md similarity index 63% rename from scripts/README.md rename to docs/DEVELOPMENT.md index 59bdb8b225..9ae38d9d68 100644 --- a/scripts/README.md +++ b/docs/DEVELOPMENT.md @@ -1,8 +1,8 @@ -# Testing Scripts +# Development & Testing Scripts -This directory contains automation scripts for testing Rocket.Chat Desktop on Linux. +This directory contains automation scripts for testing Rocket.Chat Desktop on Linux and Windows. -## Scripts +## Linux Testing Scripts ### `install-volta.sh` @@ -137,3 +137,62 @@ The script will exit with a non-zero status code if any step fails. Common issue - The app runs in the background; use `kill ` to stop it - This script is intended for development/testing purposes only +## Windows Testing Scripts + +### MSI Test Suite — DISABLE_AUTO_UPDATES (PROD-595) + +Validates that passing `DISABLE_AUTO_UPDATES=1` to the Rocket.Chat MSI installer correctly +writes `C:\Program Files\Rocket.Chat\resources\update.json` with `{"canUpdate":false,"autoUpdate":false}`. + +#### Prerequisites + +- macOS host with `sshpass`, `ssh`, `scp` in PATH +- Windows 10 VM with OpenSSH on port 22. Configure `VM_HOST`, `VM_PORT`, `VM_USER`, and `VM_PASS` in your shell before running these scripts. Example: + ```bash + export VM_HOST=192.168.13.87 VM_USER=jean VM_PASS='' + bash scripts/msi-test/run-msi-tests.sh + ``` + Never commit `VM_PASS` to the repo. +- Built MSI artifact — e.g. `dist/rocketchat-4.14.0-win-x64.msi` +- VM must be running before you invoke the orchestrator +- If the VM is reprovisioned behind the same IP, delete `scripts/msi-test/.known_hosts` before running again. + +#### Files + +| File | Runs on | Purpose | +|------|---------|---------| +| `run-msi-tests.sh` | macOS | Orchestrator: copy files, run PS1, report | +| `test-on-windows.ps1` | Windows VM | Three test scenarios | +| `fetch-logs.sh` | macOS | Pull results + install logs after a run | + +#### Scenarios + +| Scenario | What it tests | +|----------|---------------| +| A — baseline | Plain install leaves `update.json` absent | +| B — DISABLE_AUTO_UPDATES=1 | Property written as current user context | +| C — SYSTEM context (SCCM sim) | Property written via PsExec -s (SYSTEM account) | + +#### Running + +```bash +# Full run (copies an existing MSI, executes tests, prints summary) +MSI_PATH=dist/rocketchat-4.14.0-win-x64.msi ./scripts/msi-test/run-msi-tests.sh + +# Or pass as argument +./scripts/msi-test/run-msi-tests.sh dist/rocketchat-4.14.0-win-x64.msi + +# Fetch logs from a previous run without re-running tests +./scripts/msi-test/fetch-logs.sh +``` + +#### VM not reachable? + +The orchestrator will print a message with the Proxmox web UI URL if port 22 is closed. +Start the VM there, wait ~30 s, then retry. + +#### Results + +- Live output printed to stdout during run +- `scripts/msi-test/logs//test-results.json` — structured pass/fail per scenario +- `scripts/msi-test/logs//install-*.log` — verbose MSI logs from each scenario diff --git a/scripts/msi-test/README.md b/scripts/msi-test/README.md deleted file mode 100644 index 988723fa6f..0000000000 --- a/scripts/msi-test/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# MSI Test Suite — DISABLE_AUTO_UPDATES (PROD-595) - -Validates that passing `DISABLE_AUTO_UPDATES=1` to the Rocket.Chat MSI installer correctly -writes `C:\Program Files\Rocket.Chat\resources\update.json` with `{"canUpdate":false,"autoUpdate":false}`. - -## Prerequisites - -- macOS host with `sshpass`, `ssh`, `scp` in PATH -- Windows 10 VM with OpenSSH on port 22. Configure `VM_HOST`, `VM_PORT`, `VM_USER`, and `VM_PASS` in your shell before running these scripts. Example: - ``` - export VM_HOST=192.168.13.87 VM_USER=jean VM_PASS='' - bash scripts/msi-test/run-msi-tests.sh - ``` - Never commit `VM_PASS` to the repo. -- Built MSI artifact — e.g. `dist/rocketchat-4.14.0-win-x64.msi` -- VM must be running before you invoke the orchestrator -- If the VM is reprovisioned behind the same IP, delete `scripts/msi-test/.known_hosts` before running again. - -## Files - -| File | Runs on | Purpose | -|------|---------|---------| -| `run-msi-tests.sh` | macOS | Orchestrator: copy files, run PS1, report | -| `test-on-windows.ps1` | Windows VM | Three test scenarios | -| `fetch-logs.sh` | macOS | Pull results + install logs after a run | - -## Scenarios - -| Scenario | What it tests | -|----------|---------------| -| A — baseline | Plain install leaves `update.json` absent | -| B — DISABLE_AUTO_UPDATES=1 | Property written as current user context | -| C — SYSTEM context (SCCM sim) | Property written via PsExec -s (SYSTEM account) | - -## Running - -```bash -# Full run (copies an existing MSI, executes tests, prints summary) -MSI_PATH=dist/rocketchat-4.14.0-win-x64.msi ./scripts/msi-test/run-msi-tests.sh - -# Or pass as argument -./scripts/msi-test/run-msi-tests.sh dist/rocketchat-4.14.0-win-x64.msi - -# Fetch logs from a previous run without re-running tests -./scripts/msi-test/fetch-logs.sh -``` - -## VM not reachable? - -The orchestrator will print a message with the Proxmox web UI URL if port 22 is closed. -Start the VM there, wait ~30 s, then retry. - -## Results - -- Live output printed to stdout during run -- `scripts/msi-test/logs//test-results.json` — structured pass/fail per scenario -- `scripts/msi-test/logs//install-*.log` — verbose MSI logs from each scenario From f84ca8fb057e774738110f138ecf991f666fcfd0 Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 08:23:48 +0530 Subject: [PATCH 10/14] chore: Phase 1 - Move files to .github --- CODE_OF_CONDUCT.md => .github/CODE_OF_CONDUCT.md | 0 SECURITY.md => .github/SECURITY.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename CODE_OF_CONDUCT.md => .github/CODE_OF_CONDUCT.md (100%) rename SECURITY.md => .github/SECURITY.md (100%) diff --git a/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to .github/CODE_OF_CONDUCT.md diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 100% rename from SECURITY.md rename to .github/SECURITY.md From 486ffd07b1fdca61476bc96c4074e1978233f045 Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 08:23:55 +0530 Subject: [PATCH 11/14] chore: Phase 2 - Move files to docs --- DESIGN.md => docs/DESIGN.md | 0 PRODUCT.md => docs/PRODUCT.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename DESIGN.md => docs/DESIGN.md (100%) rename PRODUCT.md => docs/PRODUCT.md (100%) diff --git a/DESIGN.md b/docs/DESIGN.md similarity index 100% rename from DESIGN.md rename to docs/DESIGN.md diff --git a/PRODUCT.md b/docs/PRODUCT.md similarity index 100% rename from PRODUCT.md rename to docs/PRODUCT.md From bded384764da403a475c3c333bdca90e0d21dded Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 13:01:16 +0530 Subject: [PATCH 12/14] fix: resolve CodeRabbit review feedback for RFC 3377 --- src/injected.ts | 12 ++++-------- src/ipc/sanitize.ts | 14 +++++++++++--- src/logViewerWindow/preload.ts | 1 + src/powerMonitor/main.ts | 12 ++++++------ src/ui/main/customCssManager.ts | 5 +++-- src/ui/windowChrome/useTransparency.ts | 5 +++-- src/updates/main.ts | 5 ++--- 7 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/injected.ts b/src/injected.ts index b72bf17eff..ba79d6f72a 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -287,6 +287,7 @@ const start = async () => { console.log( `[Rocket.Chat Desktop] ${moduleName} module loaded successfully` ); + setupReactiveFeatures(); } catch (error) { console.log( `[Rocket.Chat Desktop] Failed to load ${moduleName} module:`, @@ -332,11 +333,13 @@ const start = async () => { '[Rocket.Chat Desktop] Presence module resolved without the expected store/listen API; presence will be reported as unsupported' ); presenceModuleResolved = false; + setupReactiveFeatures(); return; } Presence = (module as { Presence: unknown }).Presence; presenceModuleResolved = true; console.log('[Rocket.Chat Desktop] Presence module loaded successfully'); + setupReactiveFeatures(); }) .catch(() => { console.warn( @@ -345,6 +348,7 @@ const start = async () => { )}); presence will be reported as unsupported` ); presenceModuleResolved = false; + setupReactiveFeatures(); }); // Initialize non-module dependent features immediately @@ -1052,14 +1056,6 @@ const start = async () => { // Call setupReactiveFeatures immediately and then dynamically detect app loads setupReactiveFeatures(); - const observer = new MutationObserver(() => { - setupReactiveFeatures(); - }); - - const targetNode = document.querySelector('[data-reactroot]') || document.getElementById('react-root') || document.body; - if (targetNode) { - observer.observe(targetNode, { childList: true, subtree: true }); - } console.log('[Rocket.Chat Desktop] Injected'); }; diff --git a/src/ipc/sanitize.ts b/src/ipc/sanitize.ts index 06b1a0c6cb..fe7cf50930 100644 --- a/src/ipc/sanitize.ts +++ b/src/ipc/sanitize.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron'; -const sanitize = (args: any[]) => JSON.parse(JSON.stringify(args)); +const sanitize = (args: any[]) => structuredClone(args); // Define allowed channels for the webview preload environment. // This prevents the untrusted server view from exploiting the contextBridge @@ -68,8 +68,16 @@ const originalInvoke = ipcRenderer.invoke.bind(ipcRenderer); const originalOn = ipcRenderer.on.bind(ipcRenderer); const originalSendSync = ipcRenderer.sendSync.bind(ipcRenderer); +const isAllowedChannel = (channel: string) => { + if (ALLOWED_CHANNELS.has(channel)) { + return true; + } + const match = channel.match(/^([^@]+)@[\w-]+$/); + return !!match && ALLOWED_CHANNELS.has(match[1]); +}; + ipcRenderer.send = (channel, ...args) => { - if (!ALLOWED_CHANNELS.has(channel) && !channel.includes('@')) { + if (!isAllowedChannel(channel)) { console.warn(`Blocked unauthorized IPC send channel: ${channel}`); return; } @@ -93,7 +101,7 @@ ipcRenderer.sendSync = (channel, ...args) => { }; ipcRenderer.on = (channel, listener) => { - if (!ALLOWED_CHANNELS.has(channel) && !channel.includes('@')) { + if (!isAllowedChannel(channel)) { console.warn(`Blocked unauthorized IPC on channel: ${channel}`); return ipcRenderer; } diff --git a/src/logViewerWindow/preload.ts b/src/logViewerWindow/preload.ts index a242e9092a..bfab3b2a5f 100644 --- a/src/logViewerWindow/preload.ts +++ b/src/logViewerWindow/preload.ts @@ -14,6 +14,7 @@ const ALLOWED_INVOKE_CHANNELS = new Set([ const ALLOWED_ON_CHANNELS = new Set([ 'transparency-enabled', // This is the TRANSPARENCY_CHANNEL string from constants + 'log-viewer-window/transparency-changed', ]); const logViewer = { diff --git a/src/powerMonitor/main.ts b/src/powerMonitor/main.ts index 4b266243f1..85b72124fc 100644 --- a/src/powerMonitor/main.ts +++ b/src/powerMonitor/main.ts @@ -1,19 +1,19 @@ import { powerMonitor, webContents } from 'electron'; export const setupFrameRateThrottling = (): void => { - const throttleFrameRates = (fps: number) => { + const throttleFrameRates = (throttled: boolean) => { webContents.getAllWebContents().forEach((wc) => { try { - wc.setFrameRate(fps); + wc.setBackgroundThrottling(throttled); } catch (error) { // ignore destroyed webcontents } }); }; - powerMonitor.on('suspend', () => throttleFrameRates(10)); - powerMonitor.on('lock-screen', () => throttleFrameRates(10)); + powerMonitor.on('suspend', () => throttleFrameRates(true)); + powerMonitor.on('lock-screen', () => throttleFrameRates(true)); - powerMonitor.on('resume', () => throttleFrameRates(60)); - powerMonitor.on('unlock-screen', () => throttleFrameRates(60)); + powerMonitor.on('resume', () => throttleFrameRates(false)); + powerMonitor.on('unlock-screen', () => throttleFrameRates(false)); }; diff --git a/src/ui/main/customCssManager.ts b/src/ui/main/customCssManager.ts index 20e73fc0c1..257f3a9c6a 100644 --- a/src/ui/main/customCssManager.ts +++ b/src/ui/main/customCssManager.ts @@ -99,12 +99,13 @@ const initManager = () => { let shellTimer: NodeJS.Timeout | null = null; let workspaceTimer: NodeJS.Timeout | null = null; fs.watch(userDataPath, (_eventType, filename) => { - if (filename === 'custom-shell.css') { + if (!filename || filename === 'custom-shell.css') { if (shellTimer) clearTimeout(shellTimer); shellTimer = setTimeout(() => { reloadCache('shell', 'custom-shell.css'); }, 100); - } else if (filename === 'custom.css') { + } + if (!filename || filename === 'custom.css') { if (workspaceTimer) clearTimeout(workspaceTimer); workspaceTimer = setTimeout(() => { reloadCache('workspace', 'custom.css'); diff --git a/src/ui/windowChrome/useTransparency.ts b/src/ui/windowChrome/useTransparency.ts index 15101f3d92..40b4ec8931 100644 --- a/src/ui/windowChrome/useTransparency.ts +++ b/src/ui/windowChrome/useTransparency.ts @@ -22,10 +22,11 @@ export const useTransparency = (channel: string): boolean => { setIsEnabled(Boolean(enabled)); }; - // Use contextBridge if available (nodeIntegration: false) const bridge = (window as any).RocketChatDesktop?.logViewer; if (bridge) { - const unsubscribe = bridge.on(channel, handleChange); + const unsubscribe = bridge.on(channel, (enabled: boolean) => + handleChange(null, enabled) + ); return unsubscribe; } diff --git a/src/updates/main.ts b/src/updates/main.ts index 9723d3109c..663174b913 100644 --- a/src/updates/main.ts +++ b/src/updates/main.ts @@ -652,15 +652,14 @@ export const setupUpdates = async (): Promise => { const { stdout } = await execFileAsync('powershell.exe', [ '-NoProfile', '-Command', - `Get-AuthenticodeSignature "${downloadedFile}" | Select-Object -ExpandProperty Status`, + '& { Get-AuthenticodeSignature -LiteralPath $args[0] | Select-Object -ExpandProperty Status }', + downloadedFile, ]); if (!stdout.includes('Valid')) { throw new Error( `Signature verification failed on Windows: ${stdout}` ); } - } else if (process.platform === 'darwin') { - await execFileAsync('codesign', ['-v', downloadedFile]); } } catch (err: any) { console.error('Update integrity check failed, discarding update:', err); From d209b7c6a2979dae4cf35c5fc012392bcea1623d Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 13:22:06 +0530 Subject: [PATCH 13/14] fix(powerMonitor): throttle frame rate to 10 FPS on lock/suspend and restore original background throttling on resume --- src/powerMonitor/main.spec.ts | 98 +++++++++++++++++++++++++++++++++++ src/powerMonitor/main.ts | 45 +++++++++++++--- 2 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 src/powerMonitor/main.spec.ts diff --git a/src/powerMonitor/main.spec.ts b/src/powerMonitor/main.spec.ts new file mode 100644 index 0000000000..2f89f44ae7 --- /dev/null +++ b/src/powerMonitor/main.spec.ts @@ -0,0 +1,98 @@ +export {}; + +const powerListeners = new Map void>(); +const mockWebContents: any[] = []; + +jest.mock('electron', () => ({ + powerMonitor: { + on: (event: string, fn: () => void) => { + powerListeners.set(event, fn); + }, + }, + webContents: { + getAllWebContents: () => mockWebContents, + }, +})); + +describe('powerMonitor setupFrameRateThrottling', () => { + let mockWc1: any; + let mockWc2: any; + + beforeEach(() => { + jest.clearAllMocks(); + powerListeners.clear(); + mockWebContents.length = 0; + + mockWc1 = { + id: 1, + isDestroyed: jest.fn(() => false), + setFrameRate: jest.fn(), + setBackgroundThrottling: jest.fn(), + getWebPreferences: jest.fn(() => ({ backgroundThrottling: false })), + }; + + mockWc2 = { + id: 2, + isDestroyed: jest.fn(() => false), + setFrameRate: jest.fn(), + setBackgroundThrottling: jest.fn(), + getWebPreferences: jest.fn(() => ({ backgroundThrottling: true })), + }; + + mockWebContents.push(mockWc1, mockWc2); + + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('./main').setupFrameRateThrottling(); + }); + + it('registers suspend, lock-screen, resume, and unlock-screen listeners', () => { + expect(powerListeners.has('suspend')).toBe(true); + expect(powerListeners.has('lock-screen')).toBe(true); + expect(powerListeners.has('resume')).toBe(true); + expect(powerListeners.has('unlock-screen')).toBe(true); + }); + + it('throttles frame rate to 10 FPS and preserves original background throttling on suspend', () => { + powerListeners.get('suspend')?.(); + + expect(mockWc1.setFrameRate).toHaveBeenCalledWith(10); + expect(mockWc1.setBackgroundThrottling).toHaveBeenCalledWith(true); + + expect(mockWc2.setFrameRate).toHaveBeenCalledWith(10); + expect(mockWc2.setBackgroundThrottling).toHaveBeenCalledWith(true); + }); + + it('restores frame rate to 60 FPS and restores original background throttling on resume', () => { + powerListeners.get('suspend')?.(); + + mockWc1.setFrameRate.mockClear(); + mockWc1.setBackgroundThrottling.mockClear(); + mockWc2.setFrameRate.mockClear(); + mockWc2.setBackgroundThrottling.mockClear(); + + powerListeners.get('resume')?.(); + + expect(mockWc1.setFrameRate).toHaveBeenCalledWith(60); + expect(mockWc1.setBackgroundThrottling).toHaveBeenCalledWith(false); + + expect(mockWc2.setFrameRate).toHaveBeenCalledWith(60); + expect(mockWc2.setBackgroundThrottling).toHaveBeenCalledWith(true); + }); + + it('handles destroyed web contents gracefully', () => { + const destroyedWc = { + id: 3, + isDestroyed: jest.fn(() => true), + setFrameRate: jest.fn(), + setBackgroundThrottling: jest.fn(), + }; + mockWebContents.push(destroyedWc); + + expect(() => powerListeners.get('lock-screen')?.()).not.toThrow(); + expect(destroyedWc.setFrameRate).not.toHaveBeenCalled(); + + expect(() => powerListeners.get('unlock-screen')?.()).not.toThrow(); + expect(destroyedWc.setFrameRate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/powerMonitor/main.ts b/src/powerMonitor/main.ts index 85b72124fc..0f1ee14ceb 100644 --- a/src/powerMonitor/main.ts +++ b/src/powerMonitor/main.ts @@ -1,19 +1,50 @@ import { powerMonitor, webContents } from 'electron'; export const setupFrameRateThrottling = (): void => { - const throttleFrameRates = (throttled: boolean) => { + const originalBackgroundThrottling = new Map(); + + const throttle = () => { + webContents.getAllWebContents().forEach((wc) => { + try { + if (wc.isDestroyed()) return; + + wc.setFrameRate(10); + + if (!originalBackgroundThrottling.has(wc.id)) { + const prefs = (wc as any).getWebPreferences?.(); + originalBackgroundThrottling.set( + wc.id, + prefs?.backgroundThrottling ?? true + ); + } + wc.setBackgroundThrottling(true); + } catch (error) { + // ignore destroyed webcontents + } + }); + }; + + const restore = () => { webContents.getAllWebContents().forEach((wc) => { try { - wc.setBackgroundThrottling(throttled); + if (wc.isDestroyed()) return; + + wc.setFrameRate(60); + + const original = originalBackgroundThrottling.get(wc.id); + if (original !== undefined) { + wc.setBackgroundThrottling(original); + originalBackgroundThrottling.delete(wc.id); + } } catch (error) { // ignore destroyed webcontents } }); }; - powerMonitor.on('suspend', () => throttleFrameRates(true)); - powerMonitor.on('lock-screen', () => throttleFrameRates(true)); - - powerMonitor.on('resume', () => throttleFrameRates(false)); - powerMonitor.on('unlock-screen', () => throttleFrameRates(false)); + powerMonitor.on('suspend', throttle); + powerMonitor.on('lock-screen', throttle); + + powerMonitor.on('resume', restore); + powerMonitor.on('unlock-screen', restore); }; From d457f42b2d5b84ede91e2d6c8f6f5b732cdfb961 Mon Sep 17 00:00:00 2001 From: Abhijeet Jha Date: Sat, 10 Oct 2026 13:33:46 +0530 Subject: [PATCH 14/14] fix(powerMonitor): use getBackgroundThrottling and restrict setFrameRate to offscreen contents --- src/powerMonitor/main.spec.ts | 47 ++++++++++++++++++++++++----------- src/powerMonitor/main.ts | 18 ++++++++------ 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/powerMonitor/main.spec.ts b/src/powerMonitor/main.spec.ts index 2f89f44ae7..8d6c055d06 100644 --- a/src/powerMonitor/main.spec.ts +++ b/src/powerMonitor/main.spec.ts @@ -17,6 +17,7 @@ jest.mock('electron', () => ({ describe('powerMonitor setupFrameRateThrottling', () => { let mockWc1: any; let mockWc2: any; + let mockOffscreenWc: any; beforeEach(() => { jest.clearAllMocks(); @@ -26,20 +27,31 @@ describe('powerMonitor setupFrameRateThrottling', () => { mockWc1 = { id: 1, isDestroyed: jest.fn(() => false), + getType: jest.fn(() => 'window'), setFrameRate: jest.fn(), setBackgroundThrottling: jest.fn(), - getWebPreferences: jest.fn(() => ({ backgroundThrottling: false })), + getBackgroundThrottling: jest.fn(() => false), }; mockWc2 = { id: 2, isDestroyed: jest.fn(() => false), + getType: jest.fn(() => 'webview'), setFrameRate: jest.fn(), setBackgroundThrottling: jest.fn(), - getWebPreferences: jest.fn(() => ({ backgroundThrottling: true })), + getBackgroundThrottling: jest.fn(() => true), }; - mockWebContents.push(mockWc1, mockWc2); + mockOffscreenWc = { + id: 3, + isDestroyed: jest.fn(() => false), + getType: jest.fn(() => 'offscreen'), + setFrameRate: jest.fn(), + setBackgroundThrottling: jest.fn(), + getBackgroundThrottling: jest.fn(() => true), + }; + + mockWebContents.push(mockWc1, mockWc2, mockOffscreenWc); jest.resetModules(); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -53,46 +65,53 @@ describe('powerMonitor setupFrameRateThrottling', () => { expect(powerListeners.has('unlock-screen')).toBe(true); }); - it('throttles frame rate to 10 FPS and preserves original background throttling on suspend', () => { + it('throttles normal-rendered contents via setBackgroundThrottling and offscreen contents via setFrameRate on suspend', () => { powerListeners.get('suspend')?.(); - expect(mockWc1.setFrameRate).toHaveBeenCalledWith(10); expect(mockWc1.setBackgroundThrottling).toHaveBeenCalledWith(true); + expect(mockWc1.setFrameRate).not.toHaveBeenCalled(); - expect(mockWc2.setFrameRate).toHaveBeenCalledWith(10); expect(mockWc2.setBackgroundThrottling).toHaveBeenCalledWith(true); + expect(mockWc2.setFrameRate).not.toHaveBeenCalled(); + + expect(mockOffscreenWc.setBackgroundThrottling).toHaveBeenCalledWith(true); + expect(mockOffscreenWc.setFrameRate).toHaveBeenCalledWith(10); }); - it('restores frame rate to 60 FPS and restores original background throttling on resume', () => { + it('restores original background throttling on resume and restores offscreen frame rate', () => { powerListeners.get('suspend')?.(); - mockWc1.setFrameRate.mockClear(); mockWc1.setBackgroundThrottling.mockClear(); - mockWc2.setFrameRate.mockClear(); mockWc2.setBackgroundThrottling.mockClear(); + mockOffscreenWc.setBackgroundThrottling.mockClear(); + mockOffscreenWc.setFrameRate.mockClear(); powerListeners.get('resume')?.(); - expect(mockWc1.setFrameRate).toHaveBeenCalledWith(60); expect(mockWc1.setBackgroundThrottling).toHaveBeenCalledWith(false); + expect(mockWc1.setFrameRate).not.toHaveBeenCalled(); - expect(mockWc2.setFrameRate).toHaveBeenCalledWith(60); expect(mockWc2.setBackgroundThrottling).toHaveBeenCalledWith(true); + expect(mockWc2.setFrameRate).not.toHaveBeenCalled(); + + expect(mockOffscreenWc.setBackgroundThrottling).toHaveBeenCalledWith(true); + expect(mockOffscreenWc.setFrameRate).toHaveBeenCalledWith(60); }); it('handles destroyed web contents gracefully', () => { const destroyedWc = { - id: 3, + id: 4, isDestroyed: jest.fn(() => true), + getType: jest.fn(() => 'window'), setFrameRate: jest.fn(), setBackgroundThrottling: jest.fn(), }; mockWebContents.push(destroyedWc); expect(() => powerListeners.get('lock-screen')?.()).not.toThrow(); - expect(destroyedWc.setFrameRate).not.toHaveBeenCalled(); + expect(destroyedWc.setBackgroundThrottling).not.toHaveBeenCalled(); expect(() => powerListeners.get('unlock-screen')?.()).not.toThrow(); - expect(destroyedWc.setFrameRate).not.toHaveBeenCalled(); + expect(destroyedWc.setBackgroundThrottling).not.toHaveBeenCalled(); }); }); diff --git a/src/powerMonitor/main.ts b/src/powerMonitor/main.ts index 0f1ee14ceb..2ec67a9b44 100644 --- a/src/powerMonitor/main.ts +++ b/src/powerMonitor/main.ts @@ -8,14 +8,16 @@ export const setupFrameRateThrottling = (): void => { try { if (wc.isDestroyed()) return; - wc.setFrameRate(10); + if ((wc as any).getType?.() === 'offscreen') { + wc.setFrameRate(10); + } if (!originalBackgroundThrottling.has(wc.id)) { - const prefs = (wc as any).getWebPreferences?.(); - originalBackgroundThrottling.set( - wc.id, - prefs?.backgroundThrottling ?? true - ); + const original = + (wc as any).getBackgroundThrottling?.() ?? + (wc as any).getWebPreferences?.()?.backgroundThrottling ?? + true; + originalBackgroundThrottling.set(wc.id, original); } wc.setBackgroundThrottling(true); } catch (error) { @@ -29,7 +31,9 @@ export const setupFrameRateThrottling = (): void => { try { if (wc.isDestroyed()) return; - wc.setFrameRate(60); + if ((wc as any).getType?.() === 'offscreen') { + wc.setFrameRate(60); + } const original = originalBackgroundThrottling.get(wc.id); if (original !== undefined) {