diff --git a/electron/renderer/src/actions/index.ts b/electron/renderer/src/actions/index.ts index 5ab8b741224..1593fdfb492 100644 --- a/electron/renderer/src/actions/index.ts +++ b/electron/renderer/src/actions/index.ts @@ -191,7 +191,7 @@ export const updateAccountBadgeCount = (id: string, count: number) => { }, 0); const ignoreFlash = account?.availability === Availability.Type.BUSY; - window.sendBadgeCount(accumulatedCount, ignoreFlash); + window.electronAPI.sendBadgeCount(accumulatedCount, ignoreFlash); if (account) { const countHasChanged = account.badgeCount !== count; diff --git a/electron/renderer/src/components/WebView/Webview.tsx b/electron/renderer/src/components/WebView/Webview.tsx index 14bb0eab74f..62531bde649 100644 --- a/electron/renderer/src/components/WebView/Webview.tsx +++ b/electron/renderer/src/components/WebView/Webview.tsx @@ -38,7 +38,7 @@ import { } from '../../actions'; import {accountAction} from '../../actions/AccountAction'; import {State} from '../../index'; -import {getText, wrapperLocale} from '../../lib/locale'; +import {getText, getWrapperLocale} from '../../lib/locale'; import {WindowUrl} from '../../lib/WindowUrl'; import {AccountSelector} from '../../selector/AccountSelector'; import {Account, ConversationJoinData} from '../../types/account'; @@ -58,7 +58,7 @@ const getEnvironmentUrl = (account: Account) => { url.searchParams.set('id', account.id); // set the current language - url.searchParams.set('hl', wrapperLocale); + url.searchParams.set('hl', getWrapperLocale()); if (account.ssoCode && account.isAdding) { url.pathname = '/auth'; @@ -169,11 +169,12 @@ const Webview = ({ setWebviewError(error); } }; - webviewRef.current?.addEventListener(ON_WEBVIEW_ERROR, listener); + const webview = webviewRef.current; + webview?.addEventListener(ON_WEBVIEW_ERROR, listener); return () => { - if (webviewRef.current) { - webviewRef.current.removeEventListener(ON_WEBVIEW_ERROR, listener); + if (webview) { + webview.removeEventListener(ON_WEBVIEW_ERROR, listener); } }; }, [webviewRef, account]); @@ -211,7 +212,7 @@ const Webview = ({ case EVENT_TYPE.LIFECYCLE.SIGNED_IN: { if (conversationJoinData) { const {code, key, domain} = conversationJoinData; - window.sendConversationJoinToHost(accountId, code, key, domain); + window.electronAPI.sendConversationJoinToHost(accountId, code, key, domain); setConversationJoinData(accountId, undefined); } updateAccountLifecycle(accountId, channel); @@ -241,7 +242,7 @@ const Webview = ({ if (isConversationJoinData(data)) { if (accountLifecycle === EVENT_TYPE.LIFECYCLE.SIGNED_IN) { - window.sendConversationJoinToHost(accountId, data.code, data.key, data.domain); + window.electronAPI.sendConversationJoinToHost(accountId, data.code, data.key, data.domain); setConversationJoinData(accountId, undefined); } else { setConversationJoinData(accountId, data); @@ -272,20 +273,37 @@ const Webview = ({ } }; - webviewRef.current?.addEventListener(ON_IPC_MESSAGE, onIpcMessage); + const webview = webviewRef.current; + webview?.addEventListener(ON_IPC_MESSAGE, onIpcMessage); return () => { - if (webviewRef.current) { - webviewRef.current.removeEventListener(ON_IPC_MESSAGE, onIpcMessage); + if (webview) { + webview.removeEventListener(ON_IPC_MESSAGE, onIpcMessage); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [account, accountLifecycle, conversationJoinData]); const deleteWebview = (account: Account) => { - window.sendDeleteAccount(account.id, account.sessionID).then(() => { + // For accounts being added (no webview yet), just abort creation directly + const accountWebview = document.querySelector(`.Webview[data-accountid="${account.id}"]`); + if (!accountWebview) { + // Webview doesn't exist yet (account being added), just abort creation abortAccountCreation(account.id); - }); + return; + } + + // For existing accounts, delete the webview data first + window.electronAPI + .sendDeleteAccount(account.id, account.sessionID) + .then(() => { + abortAccountCreation(account.id); + }) + .catch(error => { + console.error('Failed to delete account:', error); + // Still abort account creation even if deletion fails + abortAccountCreation(account.id); + }); }; return ( diff --git a/electron/renderer/src/components/context/EditAccountMenu.tsx b/electron/renderer/src/components/context/EditAccountMenu.tsx index 2352fb7b6d4..20339b075b8 100644 --- a/electron/renderer/src/components/context/EditAccountMenu.tsx +++ b/electron/renderer/src/components/context/EditAccountMenu.tsx @@ -64,7 +64,7 @@ const EditAccountMenu = ({ { connected.switchWebview(accountIndex); - window.sendLogoutAccount(accountId); + window.electronAPI.sendLogoutAccount(accountId); }} > {getText('wrapperLogOut')} @@ -73,9 +73,16 @@ const EditAccountMenu = ({ { - window.sendDeleteAccount(accountId, sessionID).then(() => { - connected.abortAccountCreation(accountId); - }); + window.electronAPI + .sendDeleteAccount(accountId, sessionID) + .then(() => { + connected.abortAccountCreation(accountId); + }) + .catch(error => { + console.error('Failed to delete account:', error); + // Still abort account creation even if deletion fails + connected.abortAccountCreation(accountId); + }); }} > {getText('wrapperRemoveAccount')} diff --git a/electron/renderer/src/lib/WindowUrl.ts b/electron/renderer/src/lib/WindowUrl.ts index d648e740b11..9e4198d8003 100644 --- a/electron/renderer/src/lib/WindowUrl.ts +++ b/electron/renderer/src/lib/WindowUrl.ts @@ -17,7 +17,7 @@ * */ -import {wrapperLocale} from './locale'; +import {getWrapperLocale} from './locale'; export class WindowUrl { static createWebAppUrl(localRendererUrl: URL | string, customBackendUrl: string) { @@ -32,7 +32,7 @@ export class WindowUrl { }); // set the current language - envUrlParams.set('hl', wrapperLocale); + envUrlParams.set('hl', getWrapperLocale()); return customBackendUrlParsed.href; } diff --git a/electron/renderer/src/lib/locale.ts b/electron/renderer/src/lib/locale.ts index 064deee0af6..7ea9f7c0ead 100644 --- a/electron/renderer/src/lib/locale.ts +++ b/electron/renderer/src/lib/locale.ts @@ -17,14 +17,41 @@ * */ -import {i18nLanguageIdentifier} from '../../../src/locale'; +import {i18nLanguageIdentifier, SupportedI18nLanguage} from '../../../src/locale'; -window.locStrings = window.locStrings || {}; -window.locStringsDefault = window.locStringsDefault || {}; -window.locale = window.locale || 'en'; +// Get locale info from electronAPI (contextBridge) +// Made lazy to avoid accessing window.electronAPI before preload script has executed +const getLocaleInfo = () => { + if (window.electronAPI) { + return { + strings: window.electronAPI.locale.strings, + stringsDefault: window.electronAPI.locale.stringsDefault, + current: window.electronAPI.locale.current, + }; + } + // Fallback for development/testing or when electronAPI isn't available yet + return { + strings: {}, + stringsDefault: {}, + current: 'en' as SupportedI18nLanguage, + }; +}; + +// Cache locale info once it's available +let localeInfoCache: ReturnType | null = null; + +const getCachedLocaleInfo = () => { + if (!localeInfoCache) { + localeInfoCache = getLocaleInfo(); + } + return localeInfoCache; +}; export const getText = (stringIdentifier: i18nLanguageIdentifier, paramReplacements?: Record) => { - let str = window.locStrings[stringIdentifier] || window.locStringsDefault[stringIdentifier] || stringIdentifier; + const localeInfo = getCachedLocaleInfo(); + const strings = localeInfo.strings as Record; + const stringsDefault = localeInfo.stringsDefault as Record; + let str = strings[stringIdentifier] || stringsDefault[stringIdentifier] || stringIdentifier; const replacements = {...paramReplacements}; for (const replacement of Object.keys(replacements)) { @@ -37,4 +64,14 @@ export const getText = (stringIdentifier: i18nLanguageIdentifier, paramReplaceme return str; }; -export const wrapperLocale = window.locale; +// getWrapperLocale is used in WindowUrl.ts and Webview.tsx +// Use a getter function to always get the current locale value +// Electron guarantees preload scripts execute before renderer code, +// so window.electronAPI should always be available +export const getWrapperLocale = (): SupportedI18nLanguage => { + if (window.electronAPI) { + return window.electronAPI.locale.current; + } + // Fallback for development/testing or edge cases + return 'en' as SupportedI18nLanguage; +}; diff --git a/electron/src/main.ts b/electron/src/main.ts index d8251d8d8e4..afc71f2f92b 100644 --- a/electron/src/main.ts +++ b/electron/src/main.ts @@ -283,7 +283,7 @@ const showMainWindow = async (mainWindowState: windowStateKeeper.State): Promise title: config.name, webPreferences: { backgroundThrottling: false, - contextIsolation: false, + contextIsolation: true, nodeIntegration: false, preload: PRELOAD_JS, sandbox: false, diff --git a/electron/src/preload/menu/preload-context.ts b/electron/src/preload/menu/preload-context.ts index ec9b54de826..dddae832be0 100644 --- a/electron/src/preload/menu/preload-context.ts +++ b/electron/src/preload/menu/preload-context.ts @@ -70,20 +70,29 @@ const createDefaultMenu = (copyContext: string) => const createTextMenu = (params: ContextMenuParams, webContents: WebContents): ElectronMenu => { const {editFlags, dictionarySuggestions} = params; + // Detect if context menu is triggered from a webview + const isWebview = webContents.getType() === 'webview'; + const webContentsId = webContents.id; const template: MenuItemConstructorOptions[] = [ { - click: (_menuItem, baseWindow) => sendToWebContents(baseWindow, EVENT_TYPE.EDIT.CUT), + click: isWebview + ? () => webContents.cut() + : (_menuItem, baseWindow) => sendToWebContents(baseWindow, EVENT_TYPE.EDIT.CUT, webContentsId), enabled: editFlags.canCut, label: locale.getText('menuCut'), }, { - click: (_menuItem, baseWindow) => sendToWebContents(baseWindow, EVENT_TYPE.EDIT.COPY), + click: isWebview + ? () => webContents.copy() + : (_menuItem, baseWindow) => sendToWebContents(baseWindow, EVENT_TYPE.EDIT.COPY, webContentsId), enabled: editFlags.canCopy, label: locale.getText('menuCopy'), }, { - click: (_menuItem, baseWindow) => sendToWebContents(baseWindow, EVENT_TYPE.EDIT.PASTE), + click: isWebview + ? () => webContents.paste() + : (_menuItem, baseWindow) => sendToWebContents(baseWindow, EVENT_TYPE.EDIT.PASTE, webContentsId), enabled: editFlags.canPaste, label: locale.getText('menuPaste'), }, @@ -91,7 +100,9 @@ const createTextMenu = (params: ContextMenuParams, webContents: WebContents): El type: 'separator', }, { - click: (_menuItem, baseWindow) => sendToWebContents(baseWindow, EVENT_TYPE.EDIT.SELECT_ALL), + click: isWebview + ? () => webContents.selectAll() + : (_menuItem, baseWindow) => sendToWebContents(baseWindow, EVENT_TYPE.EDIT.SELECT_ALL, webContentsId), enabled: editFlags.canSelectAll, label: locale.getText('menuSelectAll'), }, diff --git a/electron/src/preload/preload-app.ts b/electron/src/preload/preload-app.ts index 9cc9f489837..0d2a5e1733a 100644 --- a/electron/src/preload/preload-app.ts +++ b/electron/src/preload/preload-app.ts @@ -17,11 +17,9 @@ * */ -import {ipcRenderer, webFrame} from 'electron'; +import {contextBridge, ipcRenderer, webFrame} from 'electron'; import {truncate} from 'lodash'; -import * as path from 'path'; - import {WebAppEvents} from '@wireapp/webapp-events'; import {EVENT_TYPE} from '../lib/eventType'; @@ -30,21 +28,32 @@ import {getLogger} from '../logging/getLogger'; import * as EnvironmentUtil from '../runtime/EnvironmentUtil'; import {AutomatedSingleSignOn} from '../sso/AutomatedSingleSignOn'; -const logger = getLogger(path.basename(__filename)); +const logger = getLogger('preload-app'); webFrame.setVisualZoomLevelLimits(1, 1); -window.locStrings = locale.LANGUAGES[locale.getCurrent()]; -window.locStringsDefault = locale.LANGUAGES.en; -window.locale = locale.getCurrent(); - -window.isMac = EnvironmentUtil.platform.IS_MAC_OS; - const getSelectedWebview = (): Electron.WebviewTag | null => document.querySelector('.Webview:not(.hide)'); const getWebviewById = (id: string): Electron.WebviewTag | null => document.querySelector(`.Webview[data-accountid="${id}"]`); +// Helper to find and focus a webview by webContents ID +const focusWebviewByContentsId = (webContentsId: number): Electron.WebviewTag | null => { + const webviews = document.querySelectorAll('.Webview'); + for (const webview of webviews) { + try { + if (webview.getWebContentsId() === webContentsId) { + webview.blur(); + webview.focus(); + return webview; + } + } catch (error) { + // Ignore errors when getting webContentsId + } + } + return null; +}; + const subscribeToMainProcessEvents = (): void => { ipcRenderer.on(EVENT_TYPE.ACCOUNT.SSO_LOGIN, (_event, code: string) => new AutomatedSingleSignOn().start(code)); ipcRenderer.on( @@ -78,11 +87,23 @@ const subscribeToMainProcessEvents = (): void => { } }); - ipcRenderer.on(EVENT_TYPE.EDIT.COPY, () => getSelectedWebview()?.copy()); - ipcRenderer.on(EVENT_TYPE.EDIT.CUT, () => getSelectedWebview()?.cut()); - ipcRenderer.on(EVENT_TYPE.EDIT.PASTE, () => getSelectedWebview()?.paste()); + ipcRenderer.on(EVENT_TYPE.EDIT.COPY, (_event, webContentsId?: number) => { + const targetWebview = webContentsId !== undefined ? focusWebviewByContentsId(webContentsId) : getSelectedWebview(); + targetWebview?.copy(); + }); + ipcRenderer.on(EVENT_TYPE.EDIT.CUT, (_event, webContentsId?: number) => { + const targetWebview = webContentsId !== undefined ? focusWebviewByContentsId(webContentsId) : getSelectedWebview(); + targetWebview?.cut(); + }); + ipcRenderer.on(EVENT_TYPE.EDIT.PASTE, (_event, webContentsId?: number) => { + const targetWebview = webContentsId !== undefined ? focusWebviewByContentsId(webContentsId) : getSelectedWebview(); + targetWebview?.paste(); + }); ipcRenderer.on(EVENT_TYPE.EDIT.REDO, () => getSelectedWebview()?.redo()); - ipcRenderer.on(EVENT_TYPE.EDIT.SELECT_ALL, () => getSelectedWebview()?.selectAll()); + ipcRenderer.on(EVENT_TYPE.EDIT.SELECT_ALL, (_event, webContentsId?: number) => { + const targetWebview = webContentsId !== undefined ? focusWebviewByContentsId(webContentsId) : getSelectedWebview(); + targetWebview?.selectAll(); + }); ipcRenderer.on(EVENT_TYPE.EDIT.UNDO, () => getSelectedWebview()?.undo()); ipcRenderer.on(EVENT_TYPE.WRAPPER.RELOAD, (): void => { @@ -99,16 +120,25 @@ const subscribeToMainProcessEvents = (): void => { }); }; -const setupIpcInterface = (): void => { - window.sendBadgeCount = (count: number, ignoreFlash: boolean): void => { +// Expose APIs via contextBridge for context isolation +const electronAPI = { + // Locale and environment info + locale: { + current: locale.getCurrent(), + strings: locale.LANGUAGES[locale.getCurrent()], + stringsDefault: locale.LANGUAGES.en, + }, + environment: { + isMac: EnvironmentUtil.platform.IS_MAC_OS, + }, + // IPC methods + sendBadgeCount: (count: number, ignoreFlash: boolean): void => { ipcRenderer.send(EVENT_TYPE.UI.BADGE_COUNT, {count, ignoreFlash}); - }; - - window.submitDeepLink = (url: string): void => { + }, + submitDeepLink: (url: string): void => { ipcRenderer.send(EVENT_TYPE.ACTION.DEEP_LINK_SUBMIT, url); - }; - - window.sendDeleteAccount = (accountId: string, sessionID?: string): Promise => { + }, + sendDeleteAccount: (accountId: string, sessionID?: string): Promise => { const truncatedId = truncate(accountId, {length: 5}); return new Promise((resolve, reject) => { @@ -118,32 +148,36 @@ const setupIpcInterface = (): void => { return reject(`Webview for account "${truncatedId}" does not exist`); } - logger.info(`Processing deletion of "${truncatedId}"`); - const viewInstanceId = accountWebview.getWebContentsId(); - ipcRenderer.on(EVENT_TYPE.ACCOUNT.DATA_DELETED, () => resolve()); - ipcRenderer.send(EVENT_TYPE.ACCOUNT.DELETE_DATA, viewInstanceId, accountId, sessionID); + try { + // getWebContentsId() may not be available until webview is fully attached + if (typeof accountWebview.getWebContentsId !== 'function') { + // eslint-disable-next-line prefer-promise-reject-errors + return reject(`Webview for account "${truncatedId}" is not ready (getWebContentsId not available)`); + } + + logger.info(`Processing deletion of "${truncatedId}"`); + const viewInstanceId = accountWebview.getWebContentsId(); + ipcRenderer.on(EVENT_TYPE.ACCOUNT.DATA_DELETED, () => resolve()); + ipcRenderer.send(EVENT_TYPE.ACCOUNT.DELETE_DATA, viewInstanceId, accountId, sessionID); + } catch (error) { + // eslint-disable-next-line prefer-promise-reject-errors + reject(`Failed to get webContents ID for account "${truncatedId}": ${error}`); + } }); - }; - - window.sendLogoutAccount = async (accountId: string): Promise => { + }, + sendLogoutAccount: async (accountId: string): Promise => { const accountWebview = getWebviewById(accountId); logger.log(`Sending logout signal to webview for account "${truncate(accountId, {length: 5})}".`); await accountWebview?.send(EVENT_TYPE.ACTION.SIGN_OUT); - }; - - window.sendConversationJoinToHost = async ( - accountId: string, - code: string, - key: string, - domain?: string, - ): Promise => { + }, + sendConversationJoinToHost: async (accountId: string, code: string, key: string, domain?: string): Promise => { const accountWebview = getWebviewById(accountId); logger.log(`Sending conversation join data to webview for account "${truncate(accountId, {length: 5})}".`); await accountWebview?.send(WebAppEvents.CONVERSATION.JOIN, {code, key, domain}); - }; + }, }; -setupIpcInterface(); +contextBridge.exposeInMainWorld('electronAPI', electronAPI); subscribeToMainProcessEvents(); window.addEventListener('focus', () => { diff --git a/electron/src/preload/preload-webview.ts b/electron/src/preload/preload-webview.ts index d532bf28572..190155aa169 100644 --- a/electron/src/preload/preload-webview.ts +++ b/electron/src/preload/preload-webview.ts @@ -20,8 +20,6 @@ import {ipcRenderer, webFrame} from 'electron'; import type {Data as OpenGraphResult} from 'open-graph'; -import * as path from 'path'; - import type {Availability} from '@wireapp/protocol-messaging'; import {WebAppEvents} from '@wireapp/webapp-events'; @@ -44,7 +42,7 @@ interface TeamAccountInfo { type Theme = 'dark' | 'default'; -const logger = getLogger(path.basename(__filename)); +const logger = getLogger('preload-webview'); function subscribeToThemeChange(): void { function updateWebAppTheme(): void { diff --git a/electron/src/types/globals.d.ts b/electron/src/types/globals.d.ts index a554976b502..e44480d4660 100644 --- a/electron/src/types/globals.d.ts +++ b/electron/src/types/globals.d.ts @@ -51,15 +51,21 @@ export declare global { interface Window { amplify: amplify; - isMac: boolean; - locale: SupportedI18nLanguage; - locStrings: i18nStrings; - locStringsDefault: i18nStrings; - sendBadgeCount(count: number, ignoreFlash: boolean): void; - sendConversationJoinToHost(accountId: string, code: string, key: string, domain?: string): void; - sendDeleteAccount(accountId: string, sessionID?: string): Promise; - sendLogoutAccount(accountId: string): Promise; - submitDeepLink(url: string): void; + electronAPI: { + locale: { + current: SupportedI18nLanguage; + strings: i18nStrings; + stringsDefault: i18nStrings; + }; + environment: { + isMac: boolean; + }; + sendBadgeCount(count: number, ignoreFlash: boolean): void; + sendConversationJoinToHost(accountId: string, code: string, key: string, domain?: string): void; + sendDeleteAccount(accountId: string, sessionID?: string): Promise; + sendLogoutAccount(accountId: string): Promise; + submitDeepLink(url: string): void; + }; wire: any; z: { event: {