From 93a4c6893418f4197ff17f6122090f7f252ed92d Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Thu, 18 Jun 2026 18:18:37 -0300 Subject: [PATCH 1/8] fix: share main webview session with internal video chat window Internal conferences opened via openInternalVideoChatWindow ran in a webview hardcoded to the isolated `persist:jitsi-session` partition, so they did not share cookies or localStorage with the server webview they were opened from. Electron scopes cookies/localStorage to the (partition, origin) pair, so a same-origin conference loaded unauthenticated (the login token lives in localStorage under the server origin). Load the call webview in the originating server's partition (`persist:`, resolved from the caller webContents) so the call shares the main webview's session. Because session-level handlers are per-session, sharing the session means the call window's handlers would otherwise clobber the main webview's: - Screen sharing now uses a single unified display-media handler that routes by originating frame (in-call requests open the picker in the call window; main-app requests fall back to the server-view picker), and the plain server-view handler is restored when the call closes. - The teardown permission-handler reset is skipped on a shared session so it can't disable permissions on the live main webview. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ipc/channels.ts | 1 + src/screenSharing/serverViewScreenSharing.ts | 37 +++++++++ src/videoCallWindow/ipc.ts | 79 +++++++++++++++++++- src/videoCallWindow/video-call-window.ts | 13 +++- 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index ab46a3371a..52e63a38d2 100644 --- a/src/ipc/channels.ts +++ b/src/ipc/channels.ts @@ -45,6 +45,7 @@ type ChannelToArgsMap = { success: boolean; url: string | null; autoOpenDevtools: boolean; + partition?: string; }; 'video-call-window/url-received': () => { success: boolean }; 'video-call-window/webview-created': () => { success: boolean }; diff --git a/src/screenSharing/serverViewScreenSharing.ts b/src/screenSharing/serverViewScreenSharing.ts index edad48fab3..2070891b6e 100644 --- a/src/screenSharing/serverViewScreenSharing.ts +++ b/src/screenSharing/serverViewScreenSharing.ts @@ -111,6 +111,43 @@ export const setupServerViewDisplayMedia = ( } }; +/** + * Routes a display-media request to the server-view screen picker (root window). + * Used by the video call window's unified handler when it shares the server's + * session and must dispatch a main-app request back to the server-view picker. + */ +export const handleServerViewDisplayMediaRequest = ( + callback: DisplayMediaCallback +): void => { + const dispatch = (): void => { + if (!provider) { + callback({ video: false } as any); + return; + } + try { + provider.handleDisplayMediaRequest(callback); + } catch (error) { + console.error('Server view screen sharing: error in handler:', error); + callback({ video: false } as any); + } + }; + + if (providerReady) { + dispatch(); + return; + } + + initializeProvider() + .then(dispatch) + .catch((error) => { + console.error( + 'Server view screen sharing: error initializing provider:', + error + ); + callback({ video: false } as any); + }); +}; + export const startServerViewScreenSharingHandler = (): void => { handle('screen-picker/screen-recording-is-permission-granted', async () => checkScreenRecordingPermission() diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 17932dbafb..63b6a1b123 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -23,13 +23,25 @@ import type { ScreenPickerProvider, } from '../screenSharing/screenPicker/types'; import { checkScreenRecordingPermission } from '../screenSharing/screenRecordingPermission'; +import { + handleServerViewDisplayMediaRequest, + setupServerViewDisplayMedia, +} from '../screenSharing/serverViewScreenSharing'; import { select, dispatchLocal } from '../store'; import { VIDEO_CALL_WINDOW_STATE_CHANGED } from '../ui/actions'; import { debounce } from '../ui/main/debounce'; import { handleMediaPermissionRequest } from '../ui/main/mediaPermissions'; import { isInsideSomeScreen, getRootWindow } from '../ui/main/rootWindow'; +import { + getServerUrlByWebContentsId, + getWebContentsByServerUrl, +} from '../ui/main/serverView'; import { openExternal } from '../utils/browserLauncher'; +// Alias to reach the WebContents static methods (e.g. fromFrame) from inside +// functions that shadow `webContents` with a parameter of the same name. +const electronWebContents = webContents; + const DESTRUCTION_CHECK_INTERVAL = 50; const DEVTOOLS_TIMEOUT = 2000; const WEBVIEW_CHECK_INTERVAL = 100; @@ -37,6 +49,10 @@ const WEBVIEW_CHECK_INTERVAL = 100; let videoCallWindow: BrowserWindow | null = null; let isVideoCallWindowDestroying = false; let pendingVideoCallUrl: string | null = null; +// The originating server's partition (`persist:`) so the call shares +// the main webview's cookies + localStorage. Resolved per call in the +// open-window handler; null when the caller couldn't be resolved to a server. +let pendingVideoCallPartition: string | null = null; let videoCallCredentials: { userId: string; authToken: string; @@ -121,7 +137,11 @@ const cleanupVideoCallWindow = () => { console.log( 'Stopping webview JavaScript execution before window cleanup' ); - webviewContents.session.setPermissionRequestHandler(() => false); + // Don't reset the permission handler when sharing the server's + // session — it would disable permissions on the live main webview. + if (pendingVideoCallPartition === null) { + webviewContents.session.setPermissionRequestHandler(() => false); + } webviewContents.loadURL('about:blank').catch(() => {}); } } catch (error) { @@ -200,13 +220,32 @@ const setupWebviewHandlers = (webContents: WebContents) => { const setupDisplayMediaHandler = (webviewWebContents: WebContents): void => { if (!provider) return; const currentProvider = provider; // Capture for closure + // When the call shares the server's session, this single per-session handler + // also serves the main server webview, so it must route by origin. + const isSharedSession = pendingVideoCallPartition !== null; try { // useSystemPicker is an experimental macOS 15+ option; not available on other platforms. // We set it to false unconditionally and use the callback handler on all platforms to // enable custom source selection (including PipeWire on Wayland via XDG portal). webviewWebContents.session.setDisplayMediaRequestHandler( - (_request, cb) => { + (request, cb) => { try { + // On a shared session, route by originating frame: in-call requests + // use the call window's picker; anything else (the main server + // webview) falls back to the server-view picker in the root window. + if (isSharedSession) { + const originWebContents = request.frame + ? electronWebContents.fromFrame(request.frame) + : null; + const fromCallWindow = + !!originWebContents && + originWebContents.hostWebContents?.id === + videoCallWindow?.webContents.id; + if (!fromCallWindow) { + handleServerViewDisplayMediaRequest(cb); + return; + } + } currentProvider.handleDisplayMediaRequest(cb); } catch (error) { console.error('Error in screen picker handler:', error); @@ -330,6 +369,21 @@ export const startVideoCallWindowHandler = (): void => { } } + // Always load the call webview in the originating server's partition so it + // shares the main webview's session (cookies + localStorage). + const serverUrl = getServerUrlByWebContentsId(_wc.id); + pendingVideoCallPartition = serverUrl ? `persist:${serverUrl}` : null; + if (pendingVideoCallPartition) { + console.log( + 'Video call window: sharing server session via partition', + pendingVideoCallPartition + ); + } else { + console.warn( + 'Video call window: could not resolve originating server; opening without a shared partition' + ); + } + if (isVideoCallWindowDestroying) { console.log('Waiting for video call window destruction to complete...'); await new Promise((resolve) => { @@ -473,6 +527,10 @@ export const startVideoCallWindowHandler = (): void => { skipTaskbar: false, }); + // Capture per-window so a later call opening (which resets the module + // state) can't change which server's handlers this window restores. + const windowPartition = pendingVideoCallPartition; + videoCallWindow.webContents.on( 'will-navigate', (event: Event, url: string) => { @@ -518,6 +576,19 @@ export const startVideoCallWindowHandler = (): void => { // Clean up screen sharing listener videoCallScreenSharingTracker.cleanup(); + // This call's unified handler took over the shared session's + // display-media handler. Restore the plain server-view handler so + // main-app screen sharing keeps working. The server URL is the + // partition with the `persist:` prefix stripped. + if (windowPartition) { + const serverWebContents = getWebContentsByServerUrl( + windowPartition.replace(/^persist:/, '') + ); + if (serverWebContents && !serverWebContents.isDestroyed()) { + setupServerViewDisplayMedia(serverWebContents); + } + } + // Clear credentials and provider on close videoCallCredentials = null; videoCallProviderName = null; @@ -665,6 +736,9 @@ export const startVideoCallWindowHandler = (): void => { query: { url, autoOpenDevtools: String(state.isAutoOpenEnabled), + ...(pendingVideoCallPartition && { + partition: pendingVideoCallPartition, + }), }, }) .catch((error) => { @@ -992,6 +1066,7 @@ handle('video-call-window/request-url', async () => { success: true, url: pendingVideoCallUrl, autoOpenDevtools: state.isAutoOpenEnabled, + partition: pendingVideoCallPartition ?? undefined, }; }); diff --git a/src/videoCallWindow/video-call-window.ts b/src/videoCallWindow/video-call-window.ts index eb55c0b979..6b24484452 100644 --- a/src/videoCallWindow/video-call-window.ts +++ b/src/videoCallWindow/video-call-window.ts @@ -616,7 +616,7 @@ const validateVideoCallUrl = (url: string): string => { } }; -const createWebview = (url: string): void => { +const createWebview = (url: string, partition?: string | null): void => { const container = document.getElementById('webview-container'); if (!container) { throw new Error('Webview container not found'); @@ -647,7 +647,10 @@ const createWebview = (url: string): void => { 'nodeIntegration,nativeWindowOpen=true' ); webview.setAttribute('allowpopups', 'true'); - webview.setAttribute('partition', 'persist:jitsi-session'); + // Partition is supplied by the main process (which owns the default). + if (partition) { + webview.setAttribute('partition', partition); + } webview.src = validatedUrl; webview.style.cssText = ` @@ -784,6 +787,7 @@ const start = async (): Promise => { const params = new URLSearchParams(window.location.search); let url = params.get('url'); + let partition = params.get('partition'); const autoOpenDevtools = params.get('autoOpenDevtools') === 'true'; state.shouldAutoOpenDevtools = autoOpenDevtools; @@ -804,6 +808,9 @@ const start = async (): Promise => { if (urlResult.autoOpenDevtools !== undefined) { state.shouldAutoOpenDevtools = urlResult.autoOpenDevtools; } + if (urlResult.partition) { + partition = urlResult.partition; + } } } catch (error) { console.error( @@ -825,7 +832,7 @@ const start = async (): Promise => { return; } - createWebview(url); + createWebview(url, partition); await invokeWithRetry('video-call-window/url-received', { maxAttempts: 2, From c5f4cbfa9afa6d523ed8abd7e6c3e4ac9483b2cb Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 18 Jun 2026 20:32:27 -0300 Subject: [PATCH 2/8] fix: harden video call window session sharing for production Make the POC session-share fix correct for all users. - Thread per-window state (ActiveCall record) instead of reading the mutable pendingVideoCallPartition global at lifecycle points that span async ticks; narrow the global to renderer-handshake reads only. - Restore the server-view display-media AND permission handlers from every teardown path (closed, cleanup, render-process-gone) so a shared session can't leave the main webview's screen sharing or permission prompts disabled after a call ends. Restore is idempotent. - Fix isSharedSession staleness: snapshot the per-window record at webview attach and evaluate routing at display-media request time. - Guard terminal state null-out with identity (=== capturedCall) so a stale prior-window teardown can't wipe a newer call's state. - Serialize the open-window handler via a promise-chain mutex to close a rapid-double-open race that orphaned a window with leaked handlers. - Retain persist:jitsi-session as the unresolved-server fallback so such calls keep stable isolated storage. Add ipc.main.spec.ts covering restore (shared/fallback/destroyed/ idempotent), partition assignment, render-process-gone, the null-out guard, and the serialization race. --- src/ui/main/serverView/index.ts | 94 +- src/videoCallWindow/ipc.ts | 1036 +++++++++++---------- src/videoCallWindow/main/ipc.main.spec.ts | 602 ++++++++++++ 3 files changed, 1202 insertions(+), 530 deletions(-) create mode 100644 src/videoCallWindow/main/ipc.main.spec.ts diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index fa1225cb16..7d0dc5ef30 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -9,7 +9,6 @@ import type { MediaAccessPermissionRequest, MenuItemConstructorOptions, OpenExternalPermissionRequest, - Session, UploadFile, UploadRawData, WebContents, @@ -122,6 +121,53 @@ export const getServerUrlByWebContentsId = ( )?.[0]; }; +export const setupServerViewPermissionHandler = ( + guestWebContents: WebContents, + rootWindow: BrowserWindow +): void => { + guestWebContents.session.setPermissionRequestHandler( + async (_webContents, permission, callback, details) => { + console.log('Permission request', permission, details); + switch (permission) { + case 'media': { + const { mediaTypes = [] } = details as MediaAccessPermissionRequest; + await handleMediaPermissionRequest( + mediaTypes as ReadonlyArray<'audio' | 'video'>, + rootWindow, + 'recordMessage', + callback + ); + return; + } + + case 'geolocation': + case 'notifications': + case 'midiSysex': + case 'pointerLock': + case 'fullscreen': + callback(true); + return; + + case 'openExternal': { + if (!(details as OpenExternalPermissionRequest).externalURL) { + callback(false); + return; + } + + const allowed = await isProtocolAllowed( + (details as OpenExternalPermissionRequest).externalURL as string + ); + callback(allowed); + return; + } + + default: + callback(false); + } + } + ); +}; + const initializeServerWebContentsAfterReady = ( _serverUrl: string, guestWebContents: WebContents, @@ -402,48 +448,6 @@ export const attachGuestWebContentsEvents = async (): Promise => { ); }; - const handlePermissionRequest: Parameters< - Session['setPermissionRequestHandler'] - >[0] = async (_webContents, permission, callback, details) => { - console.log('Permission request', permission, details); - switch (permission) { - case 'media': { - const { mediaTypes = [] } = details as MediaAccessPermissionRequest; - await handleMediaPermissionRequest( - mediaTypes as ReadonlyArray<'audio' | 'video'>, - rootWindow, - 'recordMessage', - callback - ); - return; - } - - case 'geolocation': - case 'notifications': - case 'midiSysex': - case 'pointerLock': - case 'fullscreen': - callback(true); - return; - - case 'openExternal': { - if (!(details as OpenExternalPermissionRequest).externalURL) { - callback(false); - return; - } - - const allowed = await isProtocolAllowed( - (details as OpenExternalPermissionRequest).externalURL as string - ); - callback(allowed); - return; - } - - default: - callback(false); - } - }; - listen(WEBVIEW_READY, (action) => { const guestWebContents = webContents.fromId( action.payload.webContentsId @@ -454,9 +458,7 @@ export const attachGuestWebContentsEvents = async (): Promise => { rootWindow ); - guestWebContents.session.setPermissionRequestHandler( - handlePermissionRequest - ); + setupServerViewPermissionHandler(guestWebContents, rootWindow); setupServerViewDisplayMedia(guestWebContents); diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 63b6a1b123..cc72e749fe 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -35,6 +35,7 @@ import { isInsideSomeScreen, getRootWindow } from '../ui/main/rootWindow'; import { getServerUrlByWebContentsId, getWebContentsByServerUrl, + setupServerViewPermissionHandler, } from '../ui/main/serverView'; import { openExternal } from '../utils/browserLauncher'; @@ -49,10 +50,23 @@ const WEBVIEW_CHECK_INTERVAL = 100; let videoCallWindow: BrowserWindow | null = null; let isVideoCallWindowDestroying = false; let pendingVideoCallUrl: string | null = null; -// The originating server's partition (`persist:`) so the call shares -// the main webview's cookies + localStorage. Resolved per call in the -// open-window handler; null when the caller couldn't be resolved to a server. +// READ ONLY by the renderer handshake (the BrowserWindow `loadFile` query +// payload, and the `video-call-window/request-url` IPC response). It carries +// the originating server's partition (`persist:`) so the call shares +// the main webview's cookies + localStorage. Do NOT read it for any lifecycle +// decision — those go through `activeCall` instead. let pendingVideoCallPartition: string | null = null; + +const FALLBACK_PARTITION = 'persist:jitsi-session'; +type ActiveCall = { + partition: string; // 'persist:' OR the fallback — always truthy + isSharedSession: boolean; // true only when a real server URL resolved + serverWebContentsId: number | null; +}; +let activeCall: ActiveCall | null = null; +// Serializes open-window requests so two near-simultaneous opens can't both pass +// the destruction/existing-window guards and race into `new BrowserWindow`. +let openWindowQueue: Promise = Promise.resolve(); let videoCallCredentials: { userId: string; authToken: string; @@ -103,7 +117,32 @@ const fetchVideoCallWindowState = async (browserWindow: BrowserWindow) => { }; }; +// Restore the plain server-view display-media handler on the originating +// server's session after a call window's unified handler took it over. Safe to +// call from every teardown path: it re-resolves the live server webContents and +// is idempotent (last-writer-wins, app-singleton provider). It must NOT null +// `activeCall` — each teardown path re-resolves the live server webContents. +const restoreServerViewHandler = async ( + call: ActiveCall | null +): Promise => { + if (!call?.isSharedSession) return; // isolated/fallback sessions: nothing to restore + const serverUrl = call.partition.replace(/^persist:/, ''); + const serverWc = getWebContentsByServerUrl(serverUrl); + if (serverWc && !serverWc.isDestroyed()) { + setupServerViewDisplayMedia(serverWc); + } + + // The shared-session teardown reset the permission handler to deny-all, + // which also kills permission prompts on the live main webview. Restore it. + const rootWindow = await getRootWindow(); + // Re-check after the await: getRootWindow yields the event loop. + if (serverWc && !serverWc.isDestroyed() && rootWindow) { + setupServerViewPermissionHandler(serverWc, rootWindow); + } +}; + const cleanupVideoCallWindow = () => { + const capturedCall = activeCall; if ( videoCallWindow && !videoCallWindow.isDestroyed() && @@ -139,7 +178,7 @@ const cleanupVideoCallWindow = () => { ); // Don't reset the permission handler when sharing the server's // session — it would disable permissions on the live main webview. - if (pendingVideoCallPartition === null) { + if (!capturedCall?.isSharedSession) { webviewContents.session.setPermissionRequestHandler(() => false); } webviewContents.loadURL('about:blank').catch(() => {}); @@ -150,6 +189,10 @@ const cleanupVideoCallWindow = () => { ); } + // Restore the server-view display-media handler that this call's unified + // handler took over (no-op on isolated/fallback sessions). + void restoreServerViewHandler(capturedCall); + // Clean up screen sharing listener before removing window listeners videoCallScreenSharingTracker.cleanup(); @@ -187,6 +230,9 @@ const cleanupVideoCallWindow = () => { videoCallWindow = null; isVideoCallWindowDestroying = false; videoCallWindowDestructionCount++; + // Only clear `activeCall` if it still belongs to this teardown — a stale + // prior-window teardown firing later must not wipe a freshly-set newer call. + if (activeCall === capturedCall) activeCall = null; console.log('Video call window cleanup completed'); logVideoCallWindowStats(); @@ -221,8 +267,10 @@ const setupWebviewHandlers = (webContents: WebContents) => { if (!provider) return; const currentProvider = provider; // Capture for closure // When the call shares the server's session, this single per-session handler - // also serves the main server webview, so it must route by origin. - const isSharedSession = pendingVideoCallPartition !== null; + // also serves the main server webview, so it must route by origin. Snapshot + // the active call at attach time; the routing decision reads it at request + // time via `call?.isSharedSession`. + const call = activeCall; try { // useSystemPicker is an experimental macOS 15+ option; not available on other platforms. // We set it to false unconditionally and use the callback handler on all platforms to @@ -233,7 +281,7 @@ const setupWebviewHandlers = (webContents: WebContents) => { // On a shared session, route by originating frame: in-call requests // use the call window's picker; anything else (the main server // webview) falls back to the server-view picker in the root window. - if (isSharedSession) { + if (call?.isSharedSession) { const originWebContents = request.frame ? electronWebContents.fromFrame(request.frame) : null; @@ -300,368 +348,330 @@ const setupWebviewHandlers = (webContents: WebContents) => { }); }; -export const startVideoCallWindowHandler = (): void => { - // Sync IPC handler for provider name - used by jitsiBridge preload - // to skip initialization for non-Jitsi providers without async delay - ipcMain.on('video-call-window/get-provider-sync', (event) => { - event.returnValue = videoCallProviderName; - }); - - handle('video-call-window/screen-recording-is-permission-granted', async () => - checkScreenRecordingPermission() - ); - - handle('video-call-window/open-url', async (_webContents, url) => { - await openExternal(url); - }); +// eslint-disable-next-line complexity +const openVideoCallWindow = async ( + _wc: WebContents, + url: string, + options?: { + providerName?: string; + credentials?: { userId: string; authToken: string }; + } +): Promise => { + console.log('Video call window: Open-window handler called with URL:', url); - handle('video-call-window/open-screen-picker', async (callerWebContents) => { - if (!videoCallWindow || videoCallWindow.isDestroyed()) { - console.warn( - 'Video call window: Cannot open screen picker - window not available' - ); - return { success: false }; + // Store provider name and credentials + videoCallProviderName = options?.providerName ?? null; + videoCallCredentials = null; + if (options?.providerName === 'pexip' && options?.credentials) { + try { + const serverOrigin = new URL(_wc.getURL()).origin; + videoCallCredentials = { + userId: options.credentials.userId, + authToken: options.credentials.authToken, + serverUrl: serverOrigin, + }; + } catch { + // _wc.getURL() may not be a valid URL in edge cases + videoCallCredentials = null; } + } - // Clean up any stale listener before registering a new one, to ensure only - // one ipcMain listener is active at a time (same pattern as createInternalPickerHandler). - videoCallScreenSharingTracker.cleanup(); - - videoCallWindow.webContents.send('video-call-window/open-screen-picker'); - - // Forward the picker response back to the calling webContents (e.g. the Jitsi webview - // preload that called ipcRenderer.invoke here). The screenSharePicker renderer sends - // the result via ipcRenderer.send → ipcMain; we relay it to the caller so that - // jitsiBridge's ipcRenderer.on listener fires correctly. - ipcMain.once( - 'video-call-window/screen-sharing-source-responded', - (_event, sourceId: string | null) => { - if (!callerWebContents.isDestroyed()) { - callerWebContents.send( - 'video-call-window/screen-sharing-source-responded', - sourceId - ); - } - } + // Always load the call webview in the originating server's partition so it + // shares the main webview's session (cookies + localStorage). When the + // server can't be resolved, fall back to an isolated jitsi-session partition. + const serverUrl = getServerUrlByWebContentsId(_wc.id); + const partition = serverUrl ? `persist:${serverUrl}` : FALLBACK_PARTITION; + activeCall = { + partition, + isSharedSession: Boolean(serverUrl), + serverWebContentsId: serverUrl ? _wc.id : null, + }; + pendingVideoCallPartition = partition; // handshake global only + if (activeCall.isSharedSession) { + console.log( + 'Video call window: sharing server session via partition', + partition ); + } else { + console.warn( + 'Video call window: could not resolve originating server; opening with isolated fallback partition', + partition + ); + } - return { success: true }; - }); - - // eslint-disable-next-line complexity - handle('video-call-window/open-window', async (_wc, url, options) => { - console.log('Video call window: Open-window handler called with URL:', url); - - // Store provider name and credentials - videoCallProviderName = options?.providerName ?? null; - videoCallCredentials = null; - if (options?.providerName === 'pexip' && options?.credentials) { - try { - const serverOrigin = new URL(_wc.getURL()).origin; - videoCallCredentials = { - userId: options.credentials.userId, - authToken: options.credentials.authToken, - serverUrl: serverOrigin, - }; - } catch { - // _wc.getURL() may not be a valid URL in edge cases - videoCallCredentials = null; - } - } + if (isVideoCallWindowDestroying) { + console.log('Waiting for video call window destruction to complete...'); + await new Promise((resolve) => { + const checkDestructionComplete = () => { + if (!isVideoCallWindowDestroying) { + resolve(); + } else { + setTimeout(checkDestructionComplete, DESTRUCTION_CHECK_INTERVAL); + } + }; + checkDestructionComplete(); + }); + } - // Always load the call webview in the originating server's partition so it - // shares the main webview's session (cookies + localStorage). - const serverUrl = getServerUrlByWebContentsId(_wc.id); - pendingVideoCallPartition = serverUrl ? `persist:${serverUrl}` : null; - if (pendingVideoCallPartition) { - console.log( - 'Video call window: sharing server session via partition', - pendingVideoCallPartition - ); - } else { - console.warn( - 'Video call window: could not resolve originating server; opening without a shared partition' - ); - } + if (videoCallWindow && !videoCallWindow.isDestroyed()) { + console.log('Closing existing video call window to create fresh one'); + videoCallWindow.close(); + videoCallWindow = null; if (isVideoCallWindowDestroying) { - console.log('Waiting for video call window destruction to complete...'); await new Promise((resolve) => { - const checkDestructionComplete = () => { + const checkClosed = () => { if (!isVideoCallWindowDestroying) { resolve(); } else { - setTimeout(checkDestructionComplete, DESTRUCTION_CHECK_INTERVAL); + setTimeout(checkClosed, DESTRUCTION_CHECK_INTERVAL); } }; - checkDestructionComplete(); + checkClosed(); }); } + } - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - console.log('Closing existing video call window to create fresh one'); - videoCallWindow.close(); - videoCallWindow = null; - - if (isVideoCallWindowDestroying) { - await new Promise((resolve) => { - const checkClosed = () => { - if (!isVideoCallWindowDestroying) { - resolve(); - } else { - setTimeout(checkClosed, DESTRUCTION_CHECK_INTERVAL); - } - }; - checkClosed(); - }); - } - } + const validUrl = new URL(url); + const allowedProtocols = ['http:', 'https:']; + console.log( + 'Video call window: URL validation - hostname:', + validUrl.hostname, + 'protocol:', + validUrl.protocol + ); - const validUrl = new URL(url); - const allowedProtocols = ['http:', 'https:']; + if (validUrl.hostname.match(/(\.)?g\.co$/)) { console.log( - 'Video call window: URL validation - hostname:', - validUrl.hostname, - 'protocol:', - validUrl.protocol + 'Video call window: Google URL detected, opening externally instead of internal window' ); + openExternal(validUrl.toString()); + return; + } + if (allowedProtocols.includes(validUrl.protocol)) { + const mainWindow = await getRootWindow(); + const winBounds = await mainWindow.getNormalBounds(); + + const centeredWindowPosition = { + x: winBounds.x + winBounds.width / 2, + y: winBounds.y + winBounds.height / 2, + }; + + const actualScreen = screen.getDisplayNearestPoint({ + x: centeredWindowPosition.x, + y: centeredWindowPosition.y, + }); - if (validUrl.hostname.match(/(\.)?g\.co$/)) { - console.log( - 'Video call window: Google URL detected, opening externally instead of internal window' + const state = select((state) => ({ + videoCallWindowState: state.videoCallWindowState, + isVideoCallWindowPersistenceEnabled: + state.isVideoCallWindowPersistenceEnabled, + isAutoOpenEnabled: state.isVideoCallDevtoolsAutoOpenEnabled, + })); + + let { x, y, width, height } = state.videoCallWindowState.bounds; + + if ( + !state.isVideoCallWindowPersistenceEnabled || + !x || + !y || + width === 0 || + height === 0 || + !isInsideSomeScreen({ x, y, width, height }) + ) { + width = Math.round(actualScreen.workAreaSize.width * 0.8); + height = Math.round(actualScreen.workAreaSize.height * 0.8); + x = Math.round( + (actualScreen.workArea.width - width) / 2 + actualScreen.workArea.x + ); + y = Math.round( + (actualScreen.workArea.height - height) / 2 + actualScreen.workArea.y ); - openExternal(validUrl.toString()); - return; } - if (allowedProtocols.includes(validUrl.protocol)) { - const mainWindow = await getRootWindow(); - const winBounds = await mainWindow.getNormalBounds(); - const centeredWindowPosition = { - x: winBounds.x + winBounds.width / 2, - y: winBounds.y + winBounds.height / 2, - }; + console.log('Creating new video call window'); + videoCallWindowCreationCount++; - const actualScreen = screen.getDisplayNearestPoint({ - x: centeredWindowPosition.x, - y: centeredWindowPosition.y, - }); + logVideoCallWindowStats(); - const state = select((state) => ({ - videoCallWindowState: state.videoCallWindowState, - isVideoCallWindowPersistenceEnabled: - state.isVideoCallWindowPersistenceEnabled, - isAutoOpenEnabled: state.isVideoCallDevtoolsAutoOpenEnabled, - })); + const additionalArgs: string[] = []; - let { x, y, width, height } = state.videoCallWindowState.bounds; + if (process.platform === 'win32') { + const sessionName = process.env.SESSIONNAME; + const isRdpSession = + typeof sessionName === 'string' && sessionName !== 'Console'; + const { readSetting } = await import('../store/readSetting'); + const isScreenCaptureFallbackEnabled = readSetting( + 'isVideoCallScreenCaptureFallbackEnabled' + ); - if ( - !state.isVideoCallWindowPersistenceEnabled || - !x || - !y || - width === 0 || - height === 0 || - !isInsideSomeScreen({ x, y, width, height }) - ) { - width = Math.round(actualScreen.workAreaSize.width * 0.8); - height = Math.round(actualScreen.workAreaSize.height * 0.8); - x = Math.round( - (actualScreen.workArea.width - width) / 2 + actualScreen.workArea.x + if (isScreenCaptureFallbackEnabled || isRdpSession) { + additionalArgs.push( + '--disable-features=WebRtcAllowWgcDesktopCapturer,WebRtcAllowWgcScreenCapturer' ); - y = Math.round( - (actualScreen.workArea.height - height) / 2 + actualScreen.workArea.y + console.log( + 'Video call window: Explicitly passing WGC disable flags to webview via additionalArguments', + { isRdpSession, isScreenCaptureFallbackEnabled } ); } + } - console.log('Creating new video call window'); - videoCallWindowCreationCount++; - - logVideoCallWindowStats(); - - const additionalArgs: string[] = []; + videoCallWindow = new BrowserWindow({ + width, + height, + x, + y, + webPreferences: { + nodeIntegration: true, + nodeIntegrationInSubFrames: true, + contextIsolation: false, + webviewTag: true, + experimentalFeatures: false, + offscreen: false, + disableHtmlFullscreenWindowResize: true, + backgroundThrottling: true, + v8CacheOptions: 'bypassHeatCheck', + spellcheck: false, + ...(additionalArgs.length > 0 && { + additionalArguments: additionalArgs, + }), + }, + show: false, + frame: true, + transparent: false, + skipTaskbar: false, + }); - if (process.platform === 'win32') { - const sessionName = process.env.SESSIONNAME; - const isRdpSession = - typeof sessionName === 'string' && sessionName !== 'Console'; - const { readSetting } = await import('../store/readSetting'); - const isScreenCaptureFallbackEnabled = readSetting( - 'isVideoCallScreenCaptureFallbackEnabled' - ); + // Capture per-window so a later call opening (which resets the module + // state) can't change which server's handlers this window restores. + const capturedCall = activeCall; - if (isScreenCaptureFallbackEnabled || isRdpSession) { - additionalArgs.push( - '--disable-features=WebRtcAllowWgcDesktopCapturer,WebRtcAllowWgcScreenCapturer' - ); - console.log( - 'Video call window: Explicitly passing WGC disable flags to webview via additionalArguments', - { isRdpSession, isScreenCaptureFallbackEnabled } - ); + videoCallWindow.webContents.on( + 'will-navigate', + (event: Event, url: string) => { + if (url.toLowerCase().startsWith('smb://')) { + event.preventDefault(); } } - - videoCallWindow = new BrowserWindow({ - width, - height, - x, - y, - webPreferences: { - nodeIntegration: true, - nodeIntegrationInSubFrames: true, - contextIsolation: false, - webviewTag: true, - experimentalFeatures: false, - offscreen: false, - disableHtmlFullscreenWindowResize: true, - backgroundThrottling: true, - v8CacheOptions: 'bypassHeatCheck', - spellcheck: false, - ...(additionalArgs.length > 0 && { - additionalArguments: additionalArgs, - }), - }, - show: false, - frame: true, - transparent: false, - skipTaskbar: false, - }); - - // Capture per-window so a later call opening (which resets the module - // state) can't change which server's handlers this window restores. - const windowPartition = pendingVideoCallPartition; - - videoCallWindow.webContents.on( - 'will-navigate', - (event: Event, url: string) => { - if (url.toLowerCase().startsWith('smb://')) { - event.preventDefault(); - } + ); + videoCallWindow.webContents.setWindowOpenHandler( + ({ url }: { url: string }) => { + if (url.toLowerCase().startsWith('smb://')) { + return { action: 'deny' }; } - ); - videoCallWindow.webContents.setWindowOpenHandler( - ({ url }: { url: string }) => { - if (url.toLowerCase().startsWith('smb://')) { - return { action: 'deny' }; - } - return { action: 'allow' }; + return { action: 'allow' }; + } + ); + + if (state.isVideoCallWindowPersistenceEnabled) { + const fetchAndDispatchWindowState = debounce(async () => { + if (videoCallWindow && !videoCallWindow.isDestroyed()) { + dispatchLocal({ + type: VIDEO_CALL_WINDOW_STATE_CHANGED, + payload: await fetchVideoCallWindowState(videoCallWindow), + }); } - ); + }, 1000); + + videoCallWindow.addListener('show', fetchAndDispatchWindowState); + videoCallWindow.addListener('hide', fetchAndDispatchWindowState); + videoCallWindow.addListener('focus', fetchAndDispatchWindowState); + videoCallWindow.addListener('blur', fetchAndDispatchWindowState); + videoCallWindow.addListener('maximize', fetchAndDispatchWindowState); + videoCallWindow.addListener('unmaximize', fetchAndDispatchWindowState); + videoCallWindow.addListener('minimize', fetchAndDispatchWindowState); + videoCallWindow.addListener('restore', fetchAndDispatchWindowState); + videoCallWindow.addListener('resize', fetchAndDispatchWindowState); + videoCallWindow.addListener('move', fetchAndDispatchWindowState); + } - if (state.isVideoCallWindowPersistenceEnabled) { - const fetchAndDispatchWindowState = debounce(async () => { - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - dispatchLocal({ - type: VIDEO_CALL_WINDOW_STATE_CHANGED, - payload: await fetchVideoCallWindowState(videoCallWindow), - }); - } - }, 1000); - - videoCallWindow.addListener('show', fetchAndDispatchWindowState); - videoCallWindow.addListener('hide', fetchAndDispatchWindowState); - videoCallWindow.addListener('focus', fetchAndDispatchWindowState); - videoCallWindow.addListener('blur', fetchAndDispatchWindowState); - videoCallWindow.addListener('maximize', fetchAndDispatchWindowState); - videoCallWindow.addListener('unmaximize', fetchAndDispatchWindowState); - videoCallWindow.addListener('minimize', fetchAndDispatchWindowState); - videoCallWindow.addListener('restore', fetchAndDispatchWindowState); - videoCallWindow.addListener('resize', fetchAndDispatchWindowState); - videoCallWindow.addListener('move', fetchAndDispatchWindowState); - } + videoCallWindow.on('closed', () => { + console.log('Video call window closed - destroying completely'); - videoCallWindow.on('closed', () => { - console.log('Video call window closed - destroying completely'); + // Clean up screen sharing listener + videoCallScreenSharingTracker.cleanup(); - // Clean up screen sharing listener - videoCallScreenSharingTracker.cleanup(); + // This call's unified handler took over the shared session's + // display-media handler. Restore the plain server-view handler so + // main-app screen sharing keeps working (no-op on isolated sessions). + void restoreServerViewHandler(capturedCall); + + // Clear credentials and provider on close + videoCallCredentials = null; + videoCallProviderName = null; - // This call's unified handler took over the shared session's - // display-media handler. Restore the plain server-view handler so - // main-app screen sharing keeps working. The server URL is the - // partition with the `persist:` prefix stripped. - if (windowPartition) { - const serverWebContents = getWebContentsByServerUrl( - windowPartition.replace(/^persist:/, '') + // Use setTimeout to ensure cleanup happens after any potential app lifecycle events + // This prevents crashes during first launch when timing is critical + setTimeout(() => { + try { + videoCallWindow = null; + isVideoCallWindowDestroying = false; + videoCallWindowDestructionCount++; + // Only clear `activeCall` if it still belongs to this window — a + // stale prior-window teardown must not wipe a freshly-set newer call. + if (activeCall === capturedCall) activeCall = null; + + logVideoCallWindowStats(); + } catch (error) { + console.error( + 'Error during video call window closed event handling:', + error ); - if (serverWebContents && !serverWebContents.isDestroyed()) { - setupServerViewDisplayMedia(serverWebContents); - } } + }, 50); // Small delay to let app state stabilize + }); - // Clear credentials and provider on close - videoCallCredentials = null; - videoCallProviderName = null; + videoCallWindow.on('close', (_event) => { + if (!isVideoCallWindowDestroying) { + isVideoCallWindowDestroying = true; + console.log( + 'Video call window close initiated - preventing JS execution' + ); - // Use setTimeout to ensure cleanup happens after any potential app lifecycle events - // This prevents crashes during first launch when timing is critical - setTimeout(() => { - try { - videoCallWindow = null; - isVideoCallWindowDestroying = false; - videoCallWindowDestructionCount++; + // Clean up screen sharing listener + videoCallScreenSharingTracker.cleanup(); - logVideoCallWindowStats(); - } catch (error) { - console.error( - 'Error during video call window closed event handling:', - error + try { + if (videoCallWindow && !videoCallWindow.isDestroyed()) { + videoCallWindow.webContents.session.setPermissionRequestHandler( + () => false ); + videoCallWindow.webContents + .executeJavaScript('void 0') + .catch(() => {}); } - }, 50); // Small delay to let app state stabilize - }); - - videoCallWindow.on('close', (_event) => { - if (!isVideoCallWindowDestroying) { - isVideoCallWindowDestroying = true; - console.log( - 'Video call window close initiated - preventing JS execution' - ); - - // Clean up screen sharing listener - videoCallScreenSharingTracker.cleanup(); - - try { - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - videoCallWindow.webContents.session.setPermissionRequestHandler( - () => false - ); - videoCallWindow.webContents - .executeJavaScript('void 0') - .catch(() => {}); - } - } catch (error) { - console.log('Error during close preparation:', error); - } + } catch (error) { + console.log('Error during close preparation:', error); } - }); + } + }); - videoCallWindow.webContents.on( - 'did-fail-load', - (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - console.error('Video call window failed to load:', { - errorCode, - errorDescription, - validatedURL, - isMainFrame, - }); + videoCallWindow.webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + console.error('Video call window failed to load:', { + errorCode, + errorDescription, + validatedURL, + isMainFrame, + }); - if (isMainFrame) { - console.error( - 'Main frame failed to load, this may indicate issues on low-power devices' - ); - } + if (isMainFrame) { + console.error( + 'Main frame failed to load, this may indicate issues on low-power devices' + ); } - ); + } + ); - videoCallWindow.webContents.on('dom-ready', () => { - if (process.env.NODE_ENV === 'development') { - console.log('Video call window DOM ready'); - } + videoCallWindow.webContents.on('dom-ready', () => { + if (process.env.NODE_ENV === 'development') { + console.log('Video call window DOM ready'); + } - videoCallWindow?.webContents - .executeJavaScript( - ` + videoCallWindow?.webContents + .executeJavaScript( + ` if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'development') { console.log('Video call window: JavaScript execution test successful'); } @@ -680,206 +690,264 @@ export const startVideoCallWindowHandler = (): void => { } }, 5000); ` - ) - .catch((error) => { - console.error( - 'Video call window: JavaScript execution test failed:', - error - ); - }); - }); - - videoCallWindow.webContents.on( - 'console-message', - (_event, level, message, line, sourceId) => { - const logPrefix = 'Video call window console:'; - switch (level) { - case 0: - console.log( - `${logPrefix} [INFO]`, - message, - `(${sourceId}:${line})` - ); - break; - case 1: - console.warn( - `${logPrefix} [WARN]`, - message, - `(${sourceId}:${line})` - ); - break; - case 2: - console.error( - `${logPrefix} [ERROR]`, - message, - `(${sourceId}:${line})` - ); - break; - default: - console.log( - `${logPrefix} [${level}]`, - message, - `(${sourceId}:${line})` - ); - } - } - ); - - const htmlPath = path.join( - app.getAppPath(), - 'app/video-call-window.html' - ); - console.log('Video call window: Loading HTML file from:', htmlPath); - - videoCallWindow - .loadFile(htmlPath, { - query: { - url, - autoOpenDevtools: String(state.isAutoOpenEnabled), - ...(pendingVideoCallPartition && { - partition: pendingVideoCallPartition, - }), - }, - }) + ) .catch((error) => { - console.error('Video call window: Failed to load HTML file:', error); console.error( - 'This may indicate build issues or file system problems on low-power devices' + 'Video call window: JavaScript execution test failed:', + error ); }); + }); - videoCallWindow.once('ready-to-show', () => { - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - videoCallWindow.setTitle(packageJsonInformation.productName); - - console.log( - 'Video call window: Window ready, waiting for renderer to signal ready state' - ); - console.log( - 'Video call window: Current pending URL:', - pendingVideoCallUrl - ); - videoCallWindow.show(); + videoCallWindow.webContents.on( + 'console-message', + (_event, level, message, line, sourceId) => { + const logPrefix = 'Video call window console:'; + switch (level) { + case 0: + console.log( + `${logPrefix} [INFO]`, + message, + `(${sourceId}:${line})` + ); + break; + case 1: + console.warn( + `${logPrefix} [WARN]`, + message, + `(${sourceId}:${line})` + ); + break; + case 2: + console.error( + `${logPrefix} [ERROR]`, + message, + `(${sourceId}:${line})` + ); + break; + default: + console.log( + `${logPrefix} [${level}]`, + message, + `(${sourceId}:${line})` + ); } + } + ); + + const htmlPath = path.join(app.getAppPath(), 'app/video-call-window.html'); + console.log('Video call window: Loading HTML file from:', htmlPath); + + videoCallWindow + .loadFile(htmlPath, { + query: { + url, + autoOpenDevtools: String(state.isAutoOpenEnabled), + ...(pendingVideoCallPartition && { + partition: pendingVideoCallPartition, + }), + }, + }) + .catch((error) => { + console.error('Video call window: Failed to load HTML file:', error); + console.error( + 'This may indicate build issues or file system problems on low-power devices' + ); }); - const { webContents } = videoCallWindow; + videoCallWindow.once('ready-to-show', () => { + if (videoCallWindow && !videoCallWindow.isDestroyed()) { + videoCallWindow.setTitle(packageJsonInformation.productName); - // Setup webview handlers (listener registered synchronously, module loads async) - setupWebviewHandlers(webContents); + console.log( + 'Video call window: Window ready, waiting for renderer to signal ready state' + ); + console.log( + 'Video call window: Current pending URL:', + pendingVideoCallUrl + ); + videoCallWindow.show(); + } + }); - // Set the pending URL after window is created to prevent race condition with cleanup - setPendingVideoCallUrl(url, 'open-window-after-creation'); - console.log( - 'Video call window: Set pending URL after window creation:', - url - ); + const { webContents } = videoCallWindow; - webContents.setWindowOpenHandler(({ url }: { url: string }) => { - console.log('Video call window - new window requested:', url); + // Setup webview handlers (listener registered synchronously, module loads async) + setupWebviewHandlers(webContents); - if (url.toLowerCase().startsWith('smb://')) { - return { action: 'deny' }; - } + // If the call window's host process crashes, the graceful 'closed' restore + // never fires — restore the server-view handler here too (idempotent). + webContents.on('render-process-gone', () => { + void restoreServerViewHandler(capturedCall); + }); - if (url.startsWith('http://') || url.startsWith('https://')) { - openExternal(url); - return { action: 'deny' }; - } + // Set the pending URL after window is created to prevent race condition with cleanup + setPendingVideoCallUrl(url, 'open-window-after-creation'); + console.log( + 'Video call window: Set pending URL after window creation:', + url + ); - return { action: 'allow' }; - }); + webContents.setWindowOpenHandler(({ url }: { url: string }) => { + console.log('Video call window - new window requested:', url); - webContents.on('will-navigate', (event: any, url: string) => { - console.log('Video call window will-navigate:', url); + if (url.toLowerCase().startsWith('smb://')) { + return { action: 'deny' }; + } - // Check for close pages and handle them specially to prevent crashes - if (url.includes('/close.html') || url.includes('/close2.html')) { - console.log( - 'Video call window: Navigation to close page detected, will handle gracefully' - ); - // Don't prevent navigation, but note it for safer handling - } + if (url.startsWith('http://') || url.startsWith('https://')) { + openExternal(url); + return { action: 'deny' }; + } - try { - const parsedUrl = new URL(url); + return { action: 'allow' }; + }); - if ( - !['http:', 'https:', 'file:', 'data:', 'about:'].includes( - parsedUrl.protocol - ) - ) { - console.log( - 'External protocol detected in video call window:', - parsedUrl.protocol - ); - event.preventDefault(); + webContents.on('will-navigate', (event: any, url: string) => { + console.log('Video call window will-navigate:', url); - isProtocolAllowed(url).then((allowed) => { - if (allowed) { - openExternal(url); - } - }); - } - } catch (e) { - console.warn('Failed to parse URL in video call window:', url, e); - } - }); + // Check for close pages and handle them specially to prevent crashes + if (url.includes('/close.html') || url.includes('/close2.html')) { + console.log( + 'Video call window: Navigation to close page detected, will handle gracefully' + ); + // Don't prevent navigation, but note it for safer handling + } - webContents.session.setPermissionRequestHandler( - async ( - _webContents: any, - permission: any, - callback: any, - details: any - ) => { + try { + const parsedUrl = new URL(url); + + if ( + !['http:', 'https:', 'file:', 'data:', 'about:'].includes( + parsedUrl.protocol + ) + ) { console.log( - 'Video call window permission request', - permission, - details + 'External protocol detected in video call window:', + parsedUrl.protocol ); - switch (permission) { - case 'media': { - const { mediaTypes = [] } = - details as MediaAccessPermissionRequest; - try { - await handleMediaPermissionRequest( - mediaTypes as ReadonlyArray<'audio' | 'video'>, - videoCallWindow, - 'initiateCall', - callback - ); - } catch (error) { - console.error( - 'Error handling media permission request in video call window:', - error - ); - callback(false); - } - return; - } + event.preventDefault(); - case 'geolocation': - case 'notifications': - case 'midiSysex': - case 'pointerLock': - case 'fullscreen': - case 'screen-wake-lock': - case 'system-wake-lock': - callback(true); - return; - - case 'openExternal': { - callback(true); - return; + isProtocolAllowed(url).then((allowed) => { + if (allowed) { + openExternal(url); } + }); + } + } catch (e) { + console.warn('Failed to parse URL in video call window:', url, e); + } + }); - default: + webContents.session.setPermissionRequestHandler( + async ( + _webContents: any, + permission: any, + callback: any, + details: any + ) => { + console.log( + 'Video call window permission request', + permission, + details + ); + switch (permission) { + case 'media': { + const { mediaTypes = [] } = details as MediaAccessPermissionRequest; + try { + await handleMediaPermissionRequest( + mediaTypes as ReadonlyArray<'audio' | 'video'>, + videoCallWindow, + 'initiateCall', + callback + ); + } catch (error) { + console.error( + 'Error handling media permission request in video call window:', + error + ); callback(false); + } + return; } + + case 'geolocation': + case 'notifications': + case 'midiSysex': + case 'pointerLock': + case 'fullscreen': + case 'screen-wake-lock': + case 'system-wake-lock': + callback(true); + return; + + case 'openExternal': { + callback(true); + return; + } + + default: + callback(false); } + } + ); + } +}; + +export const startVideoCallWindowHandler = (): void => { + // Sync IPC handler for provider name - used by jitsiBridge preload + // to skip initialization for non-Jitsi providers without async delay + ipcMain.on('video-call-window/get-provider-sync', (event) => { + event.returnValue = videoCallProviderName; + }); + + handle('video-call-window/screen-recording-is-permission-granted', async () => + checkScreenRecordingPermission() + ); + + handle('video-call-window/open-url', async (_webContents, url) => { + await openExternal(url); + }); + + handle('video-call-window/open-screen-picker', async (callerWebContents) => { + if (!videoCallWindow || videoCallWindow.isDestroyed()) { + console.warn( + 'Video call window: Cannot open screen picker - window not available' ); + return { success: false }; } + + // Clean up any stale listener before registering a new one, to ensure only + // one ipcMain listener is active at a time (same pattern as createInternalPickerHandler). + videoCallScreenSharingTracker.cleanup(); + + videoCallWindow.webContents.send('video-call-window/open-screen-picker'); + + // Forward the picker response back to the calling webContents (e.g. the Jitsi webview + // preload that called ipcRenderer.invoke here). The screenSharePicker renderer sends + // the result via ipcRenderer.send → ipcMain; we relay it to the caller so that + // jitsiBridge's ipcRenderer.on listener fires correctly. + ipcMain.once( + 'video-call-window/screen-sharing-source-responded', + (_event, sourceId: string | null) => { + if (!callerWebContents.isDestroyed()) { + callerWebContents.send( + 'video-call-window/screen-sharing-source-responded', + sourceId + ); + } + } + ); + + return { success: true }; + }); + + handle('video-call-window/open-window', (_wc, url, options) => { + const run = openWindowQueue.then(() => + openVideoCallWindow(_wc, url, options) + ); + openWindowQueue = run.catch(() => {}); // keep chain alive on failure + return run; }); handle('video-call-window/close-requested', async () => { diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts new file mode 100644 index 0000000000..2e588675c7 --- /dev/null +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -0,0 +1,602 @@ +/** + * Regression tests for the PR #3359 hardening of `src/videoCallWindow/ipc.ts`. + * + * Location note: the brief asked for `src/videoCallWindow/ipc.main.spec.ts`, but + * jest.config.js routes MAIN-process tests via + * '/src/*\/main/**\/*.(spec|test)...' and + * '/src/**\/main.(spec|test)...' + * The second pattern requires the literal filename `main.spec.ts`; a flat + * `videoCallWindow/ipc.main.spec.ts` matches NEITHER project and is silently + * never run (verified with `jest --listTests`). The established convention for + * this module is the sibling `src/videoCallWindow/main/ipc.spec.ts`, which + * matches `src/*\/main/**`. This file lives in that same `main/` dir so it is + * actually discovered and run by the main-process project. + * + * Strategy A (chosen): exercise the real module wiring. We mock `'../../ipc/main'` + * so every `handle(channel, cb)` registration captures `cb` into a map; the + * open-window behavior is then driven by invoking the captured + * `'video-call-window/open-window'` callback directly. All sibling imports of + * `ipc.ts` (electron, serverView, serverViewScreenSharing, rootWindow, store, + * etc.) are mocked. This validates the genuine flow: + * open-window callback -> openWindowQueue chain -> openVideoCallWindow -> + * activeCall assignment -> BrowserWindow creation -> 'closed'/'render-process-gone' + * listeners -> restoreServerViewHandler. + * + * `restoreServerViewHandler`, `activeCall` and `openVideoCallWindow` are + * module-internal and NOT exported. We observe them indirectly: + * - `activeCall.partition` / `isSharedSession` -> via the `loadFile` query + * `partition` arg AND via whether `restoreServerViewHandler` (fired through + * the BrowserWindow `'closed'` listener) calls `setupServerViewDisplayMedia`. + * - `restoreServerViewHandler` -> by capturing the BrowserWindow `'closed'` + * listener and the webContents `'render-process-gone'` listener and firing + * them, then asserting `setupServerViewDisplayMedia` calls. + * - serialization -> by gating the awaited `getRootWindow()` on a controllable + * deferred and asserting the 2nd BrowserWindow is not constructed until the + * 1st open body resolves. + * - null-out guard -> by driving two opens then firing the FIRST window's + * delayed `'closed'` teardown and asserting the fresh `activeCall` survives + * (observed via a subsequent restore still targeting the 2nd server). + * + * No production code was changed; Strategy B (test-only export) was not needed. + */ +import type { WebContents } from 'electron'; + +// --------------------------------------------------------------------------- +// `handle` capture: the SUT calls handle() both at module top-level and inside +// startVideoCallWindowHandler(). We record every registration into a map keyed +// by channel so tests can invoke the open-window callback directly. +// --------------------------------------------------------------------------- +const handleRegistry = new Map any>(); + +jest.mock('../../ipc/main', () => ({ + handle: jest.fn((channel: string, cb: (...args: any[]) => any) => { + handleRegistry.set(channel, cb); + return () => handleRegistry.delete(channel); + }), +})); + +// --- serverView: controls server-URL resolution and live server webContents --- +const getServerUrlByWebContentsId = jest.fn(); +const getWebContentsByServerUrl = jest.fn(); +const setupServerViewPermissionHandler = jest.fn((..._a: any[]) => undefined); +jest.mock('../../ui/main/serverView', () => ({ + getServerUrlByWebContentsId: (...a: any[]) => + getServerUrlByWebContentsId(...a), + getWebContentsByServerUrl: (...a: any[]) => getWebContentsByServerUrl(...a), + setupServerViewPermissionHandler: (...a: any[]) => + setupServerViewPermissionHandler(...a), +})); + +// --- the handler restore + routing surface under test --- +const setupServerViewDisplayMedia = jest.fn((..._a: any[]) => undefined); +const handleServerViewDisplayMediaRequest = jest.fn( + (..._a: any[]) => undefined +); +jest.mock('../../screenSharing/serverViewScreenSharing', () => ({ + setupServerViewDisplayMedia: (...a: any[]) => + setupServerViewDisplayMedia(...a), + handleServerViewDisplayMediaRequest: (...a: any[]) => + handleServerViewDisplayMediaRequest(...a), +})); + +// --- getRootWindow is the first awaited point inside openVideoCallWindow --- +// A controllable deferred lets us assert serialization ordering deterministically. +let rootWindowDeferred: { + promise: Promise; + resolve: (v: any) => void; +} | null = null; +const makeDeferred = () => { + let resolve!: (v: any) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; +const fakeRootWindow = { + getNormalBounds: jest.fn(() => ({ x: 0, y: 0, width: 1200, height: 800 })), +}; +const getRootWindow = jest.fn((..._a: any[]) => { + // Default: resolve immediately. Tests can swap in a deferred to gate it. + if (rootWindowDeferred) return rootWindowDeferred.promise; + return Promise.resolve(fakeRootWindow); +}); +const isInsideSomeScreen = jest.fn((..._a: any[]) => true); +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: (...a: any[]) => getRootWindow(...a), + isInsideSomeScreen: (...a: any[]) => isInsideSomeScreen(...a), +})); + +// --- store: select() returns a state shape sufficient for the open path --- +const select = jest.fn((..._a: any[]) => ({ + videoCallWindowState: { bounds: { x: 0, y: 0, width: 0, height: 0 } }, + isVideoCallWindowPersistenceEnabled: false, + isAutoOpenEnabled: false, +})); +const dispatchLocal = jest.fn((..._a: any[]) => undefined); +jest.mock('../../store', () => ({ + select: (...a: any[]) => select(...a), + dispatchLocal: (...a: any[]) => dispatchLocal(...a), +})); + +// --- remaining leaf imports of ipc.ts: keep them inert --- +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat' }, +})); +jest.mock('../../i18n/common', () => ({ fallbackLng: 'en' })); +jest.mock('../../navigation/main', () => ({ + isProtocolAllowed: jest.fn(() => Promise.resolve(true)), +})); +jest.mock('../../screenSharing/desktopCapturerCache', () => ({ + clearDesktopCapturerCache: jest.fn(), + getDesktopCapturerCacheStatus: jest.fn(() => ({ + cached: false, + pending: false, + })), + prewarmDesktopCapturerCache: jest.fn(), +})); +jest.mock('../../screenSharing/screenRecordingPermission', () => ({ + checkScreenRecordingPermission: jest.fn(() => Promise.resolve(true)), +})); +jest.mock('../../ui/main/debounce', () => ({ + debounce: (cb: any) => cb, +})); +jest.mock('../../ui/main/mediaPermissions', () => ({ + handleMediaPermissionRequest: jest.fn(() => Promise.resolve()), +})); +jest.mock('../../utils/browserLauncher', () => ({ + openExternal: jest.fn(), +})); +// The screen picker module is dynamically imported by setupWebviewHandlers; a +// lightweight stub keeps that async branch from throwing. +jest.mock('../../screenSharing/screenPicker', () => ({ + createScreenPicker: jest.fn(() => ({ + handleDisplayMediaRequest: jest.fn(), + })), + InternalPickerProvider: class {}, +})); +// ScreenSharingRequestTracker only needs to be constructible + .cleanup(). +jest.mock('../../screenSharing/ScreenSharingRequestTracker', () => ({ + ScreenSharingRequestTracker: class { + cleanup = jest.fn(); + + createRequest = jest.fn(); + }, +})); + +// --------------------------------------------------------------------------- +// electron mock. BrowserWindow records constructions and exposes captured +// event listeners so tests can fire 'closed' / 'render-process-gone'. +// --------------------------------------------------------------------------- +type FakeWC = { + id: number; + isDestroyed: jest.Mock; + on: jest.Mock; + once: jest.Mock; + setWindowOpenHandler: jest.Mock; + removeAllListeners: jest.Mock; + session: { setPermissionRequestHandler: jest.Mock }; + executeJavaScript: jest.Mock; + listeners: Record void>>; +}; + +type FakeBW = { + webContents: FakeWC; + loadFile: jest.Mock; + listeners: Record void>>; + loadFileQuery: any; +}; + +const createdWindows: FakeBW[] = []; +let wcIdSeq = 1000; + +const makeFakeWebContents = (): FakeWC => { + const listeners: Record void>> = {}; + const register = (event: string, fn: (...a: any[]) => void) => { + (listeners[event] ??= []).push(fn); + }; + return { + id: wcIdSeq++, + isDestroyed: jest.fn(() => false), + on: jest.fn((event: string, fn: any) => register(event, fn)), + once: jest.fn((event: string, fn: any) => register(event, fn)), + setWindowOpenHandler: jest.fn(), + removeAllListeners: jest.fn(), + session: { setPermissionRequestHandler: jest.fn() }, + executeJavaScript: jest.fn(() => Promise.resolve()), + listeners, + }; +}; + +class FakeBrowserWindow { + webContents = makeFakeWebContents(); + + loadFile = jest.fn((_path: string, opts?: any) => { + (this as unknown as FakeBW).loadFileQuery = opts?.query; + return Promise.resolve(); + }); + + listeners: Record void>> = {}; + + loadFileQuery: any = undefined; + + private register(event: string, fn: (...a: any[]) => void) { + (this.listeners[event] ??= []).push(fn); + } + + on = jest.fn((event: string, fn: any) => this.register(event, fn)); + + once = jest.fn((event: string, fn: any) => this.register(event, fn)); + + addListener = jest.fn((event: string, fn: any) => this.register(event, fn)); + + removeAllListeners = jest.fn(); + + close = jest.fn(); + + isDestroyed = jest.fn(() => false); + + setTitle = jest.fn(); + + show = jest.fn(); + + isFocused = jest.fn(() => true); + + isVisible = jest.fn(() => true); + + getNormalBounds = jest.fn(() => ({ x: 0, y: 0, width: 1200, height: 800 })); + + constructor() { + createdWindows.push(this as unknown as FakeBW); + } +} + +const screen = { + getDisplayNearestPoint: jest.fn(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + })), +}; + +jest.mock('electron', () => ({ + app: { getAppPath: jest.fn(() => '/app') }, + BrowserWindow: jest.fn().mockImplementation(() => new FakeBrowserWindow()), + ipcMain: { + on: jest.fn(), + once: jest.fn(), + handle: jest.fn(), + removeHandler: jest.fn(), + removeListener: jest.fn(), + }, + screen, + webContents: { + getAllWebContents: jest.fn(() => []), + fromFrame: jest.fn(() => null), + }, +})); + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- +const flushPromises = (): Promise => + new Promise((resolve) => setImmediate(resolve)); + +const makeCallerWc = (id: number): WebContents => + ({ + id, + getURL: jest.fn(() => 'https://server.example/'), + isDestroyed: jest.fn(() => false), + }) as unknown as WebContents; + +// Load the SUT fresh (resets module-internal `activeCall`, `openWindowQueue`, +// `videoCallWindow`) and register all handlers. +const loadModule = async () => { + const mod = await import('../ipc'); + mod.startVideoCallWindowHandler(); + const openWindow = handleRegistry.get('video-call-window/open-window'); + if (!openWindow) throw new Error('open-window handler not registered'); + return { mod, openWindow }; +}; + +// Drive a single open and wait for the queued chain + async body to settle. +const open = async ( + openWindow: (...a: any[]) => any, + callerWc: WebContents, + url = 'https://meet.example/room' +) => { + const p = openWindow(callerWc, url, undefined); + await flushPromises(); + await flushPromises(); + await p; + await flushPromises(); +}; + +// Fire a captured event listener set (window or webContents). +const fire = ( + listeners: Record void>>, + event: string, + ...args: any[] +) => { + (listeners[event] ?? []).forEach((fn) => fn(...args)); +}; + +describe('videoCallWindow/ipc — PR #3359 hardening', () => { + let realSetTimeout: typeof setTimeout; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + handleRegistry.clear(); + createdWindows.length = 0; + rootWindowDeferred = null; + wcIdSeq = 1000; + realSetTimeout = global.setTimeout; + // Re-apply default impls after clearAllMocks wiped them. + select.mockImplementation(() => ({ + videoCallWindowState: { bounds: { x: 0, y: 0, width: 0, height: 0 } }, + isVideoCallWindowPersistenceEnabled: false, + isAutoOpenEnabled: false, + })); + getRootWindow.mockImplementation(() => + rootWindowDeferred + ? rootWindowDeferred.promise + : Promise.resolve(fakeRootWindow) + ); + }); + + // ------------------------------------------------------------------------- + // open — shared vs fallback partition (behavior #1) + // ------------------------------------------------------------------------- + it('open (shared): resolvable server -> partition persist:, isSharedSession true', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(42)); + + expect(createdWindows).toHaveLength(1); + // partition propagated to the loadFile handshake query + expect(createdWindows[0].loadFileQuery.partition).toBe( + 'persist:https://chat.example' + ); + + // isSharedSession=true is proven by restore firing setupServerViewDisplayMedia: + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + fire(createdWindows[0].listeners, 'closed'); + expect(setupServerViewDisplayMedia).toHaveBeenCalledTimes(1); + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + // restore resolved the server URL stripped of the persist: prefix + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://chat.example' + ); + + // restore also re-installs the permission handler (after awaiting the + // root window) so the live main webview's prompts come back. + await flushPromises(); + expect(setupServerViewPermissionHandler).toHaveBeenCalledTimes(1); + expect(setupServerViewPermissionHandler).toHaveBeenCalledWith( + serverWc, + fakeRootWindow + ); + }); + + it('open (fallback): unresolvable server -> partition persist:jitsi-session, isSharedSession false', async () => { + getServerUrlByWebContentsId.mockReturnValue(undefined); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(7)); + + expect(createdWindows).toHaveLength(1); + expect(createdWindows[0].loadFileQuery.partition).toBe( + 'persist:jitsi-session' + ); + + // isSharedSession=false -> restore is a no-op + fire(createdWindows[0].listeners, 'closed'); + await flushPromises(); + expect(setupServerViewDisplayMedia).not.toHaveBeenCalled(); + expect(setupServerViewPermissionHandler).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // restore — shared / fallback / destroyed / idempotent (behavior #3) + // Driven through the BrowserWindow 'closed' listener (the real call site of + // restoreServerViewHandler with the captured call). + // ------------------------------------------------------------------------- + it('restore (shared): live, non-destroyed server webContents -> setupServerViewDisplayMedia called once', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://a.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + + fire(createdWindows[0].listeners, 'closed'); + + expect(setupServerViewDisplayMedia).toHaveBeenCalledTimes(1); + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + }); + + it('restore (fallback): isSharedSession=false -> setupServerViewDisplayMedia NOT called', async () => { + getServerUrlByWebContentsId.mockReturnValue(undefined); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + getWebContentsByServerUrl.mockReturnValue({ + isDestroyed: jest.fn(() => false), + }); + fire(createdWindows[0].listeners, 'closed'); + + expect(getWebContentsByServerUrl).not.toHaveBeenCalled(); + expect(setupServerViewDisplayMedia).not.toHaveBeenCalled(); + }); + + it('restore (unresolved server): getWebContentsByServerUrl returns undefined -> NOT called', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://gone.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + getWebContentsByServerUrl.mockReturnValue(undefined); + fire(createdWindows[0].listeners, 'closed'); + + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://gone.example' + ); + expect(setupServerViewDisplayMedia).not.toHaveBeenCalled(); + }); + + it('restore (destroyed server): isDestroyed()===true -> NOT called', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://dead.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + getWebContentsByServerUrl.mockReturnValue({ + isDestroyed: jest.fn(() => true), + }); + fire(createdWindows[0].listeners, 'closed'); + + expect(setupServerViewDisplayMedia).not.toHaveBeenCalled(); + }); + + it('restore (idempotent): firing restore via closed + render-process-gone 3x -> called 3x, no throw', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://idem.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + + // three independent restore invocations across the real call sites: + expect(() => { + fire(createdWindows[0].listeners, 'closed'); // window 'closed' + fire(createdWindows[0].webContents.listeners, 'render-process-gone'); // crash path + fire(createdWindows[0].listeners, 'closed'); // again + }).not.toThrow(); + + expect(setupServerViewDisplayMedia).toHaveBeenCalledTimes(3); + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + + // The permission-handler restore is awaited; flush, then it must mirror the + // display-media restore (once per invocation). + await flushPromises(); + expect(setupServerViewPermissionHandler).toHaveBeenCalledTimes(3); + expect(setupServerViewPermissionHandler).toHaveBeenCalledWith( + serverWc, + fakeRootWindow + ); + }); + + // ------------------------------------------------------------------------- + // render-process-gone restore path (behavior #3, crash entry) + // ------------------------------------------------------------------------- + it('restore via render-process-gone: shared session restores handler', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://crash.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + + fire(createdWindows[0].webContents.listeners, 'render-process-gone'); + + expect(setupServerViewDisplayMedia).toHaveBeenCalledTimes(1); + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + }); + + // ------------------------------------------------------------------------- + // null-out guard (behavior #5) + // A stale prior-window 'closed' teardown firing AFTER a fresh open must not + // wipe the freshly-set activeCall. We prove the fresh activeCall survives by + // showing a restore for the SECOND server still works after the first + // window's delayed teardown ran. + // ------------------------------------------------------------------------- + it('null-out guard: stale first-window teardown does not wipe fresh activeCall', async () => { + // First open -> server A + getServerUrlByWebContentsId.mockReturnValue('https://first.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + const firstWindow = createdWindows[0]; + + // Second open -> server B (fresh activeCall = B). The existing-window guard + // in openVideoCallWindow closes the first window synchronously. + getServerUrlByWebContentsId.mockReturnValue('https://second.example'); + await open(openWindow, makeCallerWc(2)); + expect(createdWindows).toHaveLength(2); + const secondWindow = createdWindows[1]; + + // Now fire the FIRST window's 'closed' teardown. Its captured call (A) !== + // current activeCall (B), so the `if (activeCall === capturedCall)` guard + // must NOT null activeCall. We let its 50ms setTimeout run. + fire(firstWindow.listeners, 'closed'); + await new Promise((r) => realSetTimeout(r, 80)); + + // activeCall must still be B: firing the SECOND window's restore resolves + // server B (not A, not undefined). + const serverBWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverBWc); + fire(secondWindow.listeners, 'closed'); + + expect(getWebContentsByServerUrl).toHaveBeenLastCalledWith( + 'https://second.example' + ); + expect(setupServerViewDisplayMedia).toHaveBeenLastCalledWith(serverBWc); + }); + + // ------------------------------------------------------------------------- + // serialization / race (behavior #6) + // openWindowQueue chains opens; getRootWindow() is the first awaited point in + // the body. Gating it on a deferred proves the 2nd open's BrowserWindow is + // NOT constructed until the 1st open's body completes. + // ------------------------------------------------------------------------- + it('serialization: two unawaited opens construct BrowserWindows serially, last open wins', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://serial.example'); + const { openWindow } = await loadModule(); + + // Gate the FIRST open at getRootWindow. + const d1 = makeDeferred(); + rootWindowDeferred = d1; + + const p1 = openWindow(makeCallerWc(1), 'https://meet.example/a', undefined); + const p2 = openWindow(makeCallerWc(2), 'https://meet.example/b', undefined); + await flushPromises(); + await flushPromises(); + + // First open is blocked at getRootWindow; second open must not have run its + // body yet because the queue serializes on p1. No window constructed. + expect(createdWindows).toHaveLength(0); + + // Release the first open. From here the queue lets the second proceed; swap + // back to immediate resolution so the second open's getRootWindow resolves. + rootWindowDeferred = null; + d1.resolve(fakeRootWindow); + await flushPromises(); + await flushPromises(); + await p1; + await flushPromises(); + await flushPromises(); + await p2; + await flushPromises(); + + // Both completed; exactly two windows created (one per open), in order. + expect(createdWindows).toHaveLength(2); + // The LAST open wins: loadFile carried the second URL. + expect(createdWindows[1].loadFileQuery.url).toBe('https://meet.example/b'); + }); + + // ------------------------------------------------------------------------- + // cleanup restore path (behavior #2 gate + behavior #3 restore) + // cleanupVideoCallResources() -> cleanupVideoCallWindow() -> restore. + // ------------------------------------------------------------------------- + it('cleanupVideoCallResources triggers restore for shared session', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://cleanup.example'); + const { mod, openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + + mod.cleanupVideoCallResources(); + await flushPromises(); + + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + }); +}); From 10f31d50f2f8cf0956b599c87e344c9b4920ff65 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 23 Jun 2026 20:04:39 -0300 Subject: [PATCH 3/8] feat: openInMainWindow bridge to navigate the main window from the video call window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone internal video-chat window is its own BrowserWindow with no window.opener, so the web app's window.open/opener trick can't reach the main window — it just spawns another window. Add an IPC path so the conference page can ask the main app window to focus itself and navigate to an in-app route. Caller (video-chat window): - window.videoCallWindow.openInMainWindow(path) — validates that path is an in-app relative route ("/..."), rejecting absolute/protocol-relative/scheme URLs, then invokes 'video-call-window/open-in-main-window'. Main process: - New handler resolves the target server webview (caller's own server, else the active currentView.url), shows/restores/focuses the main window, and emits 'navigate-to-route' (payload: path) to that server webview's webContents. It intentionally does NOT loadURL — that would hard-reload the SPA. No-ops safely with a warning when no window/webview is found, and re-validates the path. Receiver (server webview is contextIsolated, so a raw send lands in the preload, not the page): - New navigateToRoute preload relay listens on 'navigate-to-route' and forwards to a RocketChatDesktop.onNavigateToRoute(callback) the web client registers, buffering the latest path if it arrives before registration (mirrors the telephony relay). Also: Cmd/Ctrl+Shift+D ("Toggle Developer Tools") now targets the focused window (falling back to the main window), so it can open DevTools for the video call window instead of always the main one. Web-repo follow-up (out of scope here): the web client must call RocketChatDesktop.onNavigateToRoute(path => router.navigate(path)) for the route change to take effect. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ipc/channels.ts | 1 + src/preload.ts | 2 + src/servers/preload/api.ts | 3 + src/servers/preload/navigateToRoute.spec.ts | 65 ++++++++++ src/servers/preload/navigateToRoute.ts | 36 ++++++ src/ui/main/menuBar.ts | 6 +- src/videoCallWindow/ipc.ts | 59 +++++++++ src/videoCallWindow/main/ipc.main.spec.ts | 125 ++++++++++++++++++++ src/videoCallWindow/preload/index.ts | 16 +++ 9 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 src/servers/preload/navigateToRoute.spec.ts create mode 100644 src/servers/preload/navigateToRoute.ts diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index 52e63a38d2..05f68e4816 100644 --- a/src/ipc/channels.ts +++ b/src/ipc/channels.ts @@ -32,6 +32,7 @@ type ChannelToArgsMap = { } ) => void; 'video-call-window/open-url': (url: string) => void; + 'video-call-window/open-in-main-window': (path: string) => void; 'video-call-window/web-contents-id': (webContentsId: number) => void; 'video-call-window/open-screen-picker': () => { success: boolean }; 'video-call-window/screen-sharing-source-responded': (source: string) => void; diff --git a/src/preload.ts b/src/preload.ts index f5c6e32b90..32bc35e906 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -6,6 +6,7 @@ import { JitsiMeetElectron } from './jitsi/preload'; import { listenToNotificationsRequests } from './notifications/preload'; import { listenToScreenSharingRequests } from './screenSharing/preload'; import { RocketChatDesktop } from './servers/preload/api'; +import { listenToNavigateToRouteRequests } from './servers/preload/navigateToRoute'; import { setServerUrl } from './servers/preload/urls'; import { createRendererReduxStore, listen } from './store'; import { listenToTelephonyRequests } from './telephony/preload'; @@ -66,6 +67,7 @@ const start = async (): Promise => { await invoke('server-view/ready'); listenToTelephonyRequests(); + listenToNavigateToRouteRequests(); console.log('[Rocket.Chat Desktop] waiting for RocketChatDesktop.onReady'); RocketChatDesktop.onReady(() => { diff --git a/src/servers/preload/api.ts b/src/servers/preload/api.ts index e6aa02a17d..6998e053e4 100644 --- a/src/servers/preload/api.ts +++ b/src/servers/preload/api.ts @@ -29,6 +29,7 @@ import { getInternalVideoChatWindowEnabled, openInternalVideoChatWindow, } from './internalVideoChatWindow'; +import { onNavigateToRoute } from './navigateToRoute'; import { openInBrowser } from './openInBrowser'; import { reloadServer } from './reloadServer'; import { @@ -59,6 +60,7 @@ type ExtendedIRocketChatDesktop = IRocketChatDesktop & { callback: (payload: { phoneNumber: string; rawUri: string }) => void ) => void; supportedDocumentViewerFormats: () => string[]; + onNavigateToRoute: (callback: (path: string) => void) => void; }; declare global { @@ -108,4 +110,5 @@ export const RocketChatDesktop: Window['RocketChatDesktop'] = { reloadServer, getE2ePdfPreviewSizeLimit, onTelephonyCallRequested, + onNavigateToRoute, }; diff --git a/src/servers/preload/navigateToRoute.spec.ts b/src/servers/preload/navigateToRoute.spec.ts new file mode 100644 index 0000000000..5f5bc36cbd --- /dev/null +++ b/src/servers/preload/navigateToRoute.spec.ts @@ -0,0 +1,65 @@ +type IpcListener = (event: unknown, path: string) => void; + +const ipcListeners = new Map(); +const on = jest.fn((channel: string, listener: IpcListener) => { + ipcListeners.set(channel, listener); +}); + +jest.mock('electron', () => ({ + ipcRenderer: { + on: (channel: string, listener: IpcListener) => on(channel, listener), + }, +})); + +const emit = (path: string) => { + const listener = ipcListeners.get('navigate-to-route'); + if (!listener) throw new Error('navigate-to-route listener not registered'); + listener({}, path); +}; + +describe('servers/preload/navigateToRoute', () => { + let onNavigateToRoute: (cb: (path: string) => void) => void; + let listenToNavigateToRouteRequests: () => void; + + beforeEach(async () => { + jest.resetModules(); + ipcListeners.clear(); + on.mockClear(); + const mod = await import('./navigateToRoute'); + onNavigateToRoute = mod.onNavigateToRoute; + listenToNavigateToRouteRequests = mod.listenToNavigateToRouteRequests; + }); + + it('registers the ipcRenderer listener only once', () => { + listenToNavigateToRouteRequests(); + listenToNavigateToRouteRequests(); + expect(on).toHaveBeenCalledTimes(1); + expect(on).toHaveBeenCalledWith('navigate-to-route', expect.any(Function)); + }); + + it('delivers the path to a callback registered before the event', () => { + listenToNavigateToRouteRequests(); + const cb = jest.fn(); + onNavigateToRoute(cb); + + emit('/channel/general'); + + expect(cb).toHaveBeenCalledWith('/channel/general'); + }); + + it('buffers a path that arrives before the callback registers, then flushes once', () => { + listenToNavigateToRouteRequests(); + + emit('/admin/rooms'); + + const cb = jest.fn(); + onNavigateToRoute(cb); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith('/admin/rooms'); + + // The buffered path is consumed: a later registration gets nothing extra. + const cb2 = jest.fn(); + onNavigateToRoute(cb2); + expect(cb2).not.toHaveBeenCalled(); + }); +}); diff --git a/src/servers/preload/navigateToRoute.ts b/src/servers/preload/navigateToRoute.ts new file mode 100644 index 0000000000..2c8bb4785c --- /dev/null +++ b/src/servers/preload/navigateToRoute.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron'; + +let navigateCallback: ((path: string) => void) | null = null; +let pendingPath: string | null = null; + +// Registered by the web client to receive in-app route changes requested by the +// desktop shell (e.g. from the standalone video call window). `path` is a +// server-relative route, e.g. "/channel/general". +export const onNavigateToRoute = (callback: (path: string) => void): void => { + navigateCallback = callback; + if (pendingPath) { + callback(pendingPath); + pendingPath = null; + } +}; + +let listening = false; + +// Relays the main-process 'navigate-to-route' event (delivered to this preload's +// ipcRenderer, since the server webview is contextIsolated) to the web client's +// callback. Buffers the latest path if a request arrives before the web client +// has registered its handler. +export const listenToNavigateToRouteRequests = (): void => { + if (listening) { + return; + } + listening = true; + + ipcRenderer.on('navigate-to-route', (_event, path: string) => { + if (navigateCallback) { + navigateCallback(path); + } else { + pendingPath = path; + } + }); +}; diff --git a/src/ui/main/menuBar.ts b/src/ui/main/menuBar.ts index c2d71eaab2..82f1f491a2 100644 --- a/src/ui/main/menuBar.ts +++ b/src/ui/main/menuBar.ts @@ -605,7 +605,11 @@ const createHelpMenu = createSelector( label: t('menus.toggleDevTools'), accelerator: 'CommandOrControl+Shift+D', click: async () => { - const browserWindow = await getRootWindow(); + // Target the focused window (e.g. the video call window) so DevTools + // open where the user is looking; fall back to the main window when + // nothing is focused. + const browserWindow = + BrowserWindow.getFocusedWindow() ?? (await getRootWindow()); if (!browserWindow.isVisible()) { browserWindow.showInactive(); diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index cc72e749fe..70490f3ba8 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -909,6 +909,65 @@ export const startVideoCallWindowHandler = (): void => { await openExternal(url); }); + // Bring the main app window to the front and ask the active server's web + // client to navigate to an in-app route. Used by the standalone video-chat + // window, which has no window.opener and therefore can't reach the main + // window via the web app's window.open trick. + handle( + 'video-call-window/open-in-main-window', + async (callerWebContents, path) => { + // Defense in depth (the preload validates too): only accept in-app + // relative routes — reject absolute/protocol-relative/scheme URLs. + if ( + typeof path !== 'string' || + !path.startsWith('/') || + path.startsWith('//') || + path.startsWith('/\\') + ) { + console.warn( + 'Video call window: open-in-main-window rejected non-relative path:', + path + ); + return; + } + + // Resolve the target server webview: prefer the caller's own server, + // otherwise the server currently active in the main window. + let serverUrl = getServerUrlByWebContentsId(callerWebContents.id); + if (!serverUrl) { + const currentView = select((state) => state.currentView); + if (typeof currentView === 'object' && currentView.url) { + serverUrl = currentView.url; + } + } + + const serverWebContents = serverUrl + ? getWebContentsByServerUrl(serverUrl) + : undefined; + if (!serverWebContents || serverWebContents.isDestroyed()) { + console.warn( + 'Video call window: open-in-main-window could not find a target server webview for', + serverUrl + ); + return; + } + + // Bring the main window to the foreground. + const rootWindow = await getRootWindow(); + if (rootWindow && !rootWindow.isDestroyed()) { + if (rootWindow.isMinimized()) { + rootWindow.restore(); + } + rootWindow.show(); + rootWindow.focus(); + } + + // Client-side route change. NOT a loadURL — that would hard-reload the + // SPA. The web client listens for this event and calls its router. + serverWebContents.send('navigate-to-route', path); + } + ); + handle('video-call-window/open-screen-picker', async (callerWebContents) => { if (!videoCallWindow || videoCallWindow.isDestroyed()) { console.warn( diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts index 2e588675c7..9b629fb3c7 100644 --- a/src/videoCallWindow/main/ipc.main.spec.ts +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -94,6 +94,11 @@ const makeDeferred = () => { }; const fakeRootWindow = { getNormalBounds: jest.fn(() => ({ x: 0, y: 0, width: 1200, height: 800 })), + isDestroyed: jest.fn(() => false), + isMinimized: jest.fn(() => false), + restore: jest.fn(), + show: jest.fn(), + focus: jest.fn(), }; const getRootWindow = jest.fn((..._a: any[]) => { // Default: resolve immediately. Tests can swap in a deferred to gate it. @@ -341,6 +346,10 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { ? rootWindowDeferred.promise : Promise.resolve(fakeRootWindow) ); + // clearAllMocks() keeps mockReturnValue impls, so reset the root-window + // window-state methods to deterministic defaults for each test. + fakeRootWindow.isDestroyed.mockReturnValue(false); + fakeRootWindow.isMinimized.mockReturnValue(false); }); // ------------------------------------------------------------------------- @@ -599,4 +608,120 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); }); + + // ------------------------------------------------------------------------- + // open-in-main-window: focus the main window + emit 'navigate-to-route' + // ------------------------------------------------------------------------- + describe('open-in-main-window', () => { + const loadHandler = async () => { + await loadModule(); + const handler = handleRegistry.get( + 'video-call-window/open-in-main-window' + ); + if (!handler) + throw new Error('open-in-main-window handler not registered'); + return handler; + }; + + const makeServerWc = () => ({ + isDestroyed: jest.fn(() => false), + send: jest.fn(), + }); + + it("caller's server resolves -> focuses main window and emits navigate-to-route", async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const serverWc = makeServerWc(); + getWebContentsByServerUrl.mockReturnValue(serverWc); + + const handler = await loadHandler(); + await handler(makeCallerWc(42), '/channel/general'); + + expect(getServerUrlByWebContentsId).toHaveBeenCalledWith(42); + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://chat.example' + ); + expect(fakeRootWindow.show).toHaveBeenCalledTimes(1); + expect(fakeRootWindow.focus).toHaveBeenCalledTimes(1); + expect(serverWc.send).toHaveBeenCalledWith( + 'navigate-to-route', + '/channel/general' + ); + }); + + it('falls back to the active server when the caller is unresolved', async () => { + getServerUrlByWebContentsId.mockReturnValue(undefined); + select.mockImplementation((sel: any) => + sel({ currentView: { url: 'https://active.example' } }) + ); + const serverWc = makeServerWc(); + getWebContentsByServerUrl.mockReturnValue(serverWc); + + const handler = await loadHandler(); + await handler(makeCallerWc(99), '/admin/rooms'); + + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://active.example' + ); + expect(serverWc.send).toHaveBeenCalledWith( + 'navigate-to-route', + '/admin/rooms' + ); + }); + + it('restores the main window when minimized', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + getWebContentsByServerUrl.mockReturnValue(makeServerWc()); + fakeRootWindow.isMinimized.mockReturnValue(true); + + const handler = await loadHandler(); + await handler(makeCallerWc(1), '/channel/general'); + + expect(fakeRootWindow.restore).toHaveBeenCalledTimes(1); + }); + + it.each([ + '//evil.example', + 'https://evil.example', + '/\\evil.example', + 'channel/general', + ])( + 'rejects non-relative path %p -> no focus, no navigate', + async (badPath) => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const serverWc = makeServerWc(); + getWebContentsByServerUrl.mockReturnValue(serverWc); + + const handler = await loadHandler(); + await handler(makeCallerWc(1), badPath); + + expect(serverWc.send).not.toHaveBeenCalled(); + expect(fakeRootWindow.focus).not.toHaveBeenCalled(); + } + ); + + it('no-ops safely when the target server webview is missing', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + getWebContentsByServerUrl.mockReturnValue(undefined); + + const handler = await loadHandler(); + await expect( + handler(makeCallerWc(1), '/channel/general') + ).resolves.toBeUndefined(); + + expect(fakeRootWindow.focus).not.toHaveBeenCalled(); + }); + + it('no-ops safely when the target server webview is destroyed', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + getWebContentsByServerUrl.mockReturnValue({ + isDestroyed: jest.fn(() => true), + send: jest.fn(), + }); + + const handler = await loadHandler(); + await handler(makeCallerWc(1), '/channel/general'); + + expect(fakeRootWindow.focus).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/videoCallWindow/preload/index.ts b/src/videoCallWindow/preload/index.ts index 3c28ab6f70..51bd515608 100644 --- a/src/videoCallWindow/preload/index.ts +++ b/src/videoCallWindow/preload/index.ts @@ -1,8 +1,24 @@ import { contextBridge, ipcRenderer } from 'electron'; import './jitsiBridge'; +// Accept only in-app relative routes ("/..."), rejecting absolute URLs, +// protocol-relative URLs ("//host") and the backslash variant ("/\\host") so +// this can't become an open-redirect / arbitrary-navigation primitive. +const isRelativeRoute = (path: unknown): path is string => + typeof path === 'string' && + path.startsWith('/') && + !path.startsWith('//') && + !path.startsWith('/\\'); + // Expose any necessary APIs to the webview content contextBridge.exposeInMainWorld('videoCallWindow', { + // Navigate the main app window to an in-app route and bring it to the front. + // `path` is a server-relative route, e.g. "/channel/general". + openInMainWindow: (path: string) => { + if (isRelativeRoute(path)) { + ipcRenderer.invoke('video-call-window/open-in-main-window', path); + } + }, // Add methods here if needed for communication with the main process requestScreenSharing: async () => { // Directly invoke the screen picker From 3e93e6cfd115fd789a175b56f2fd746eac27a016 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 24 Jun 2026 08:03:31 -0300 Subject: [PATCH 4/8] fix: route open-in-main-window to the call's origin server When the standalone video call window asks the main window to navigate, the handler resolved the target server from the caller webContents and fell back to whichever server was active in the main window. In a multi-workspace setup that could navigate a *different* server than the one the call belongs to. Resolve in priority order: caller's own server, then the active call's origin server (authoritative, via activeCall.serverWebContentsId), then the active view as a last-resort guess (now logged as ambiguous). Also log in the preload bridge when a non-relative path is rejected, for parity with the main-process handler. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/videoCallWindow/ipc.ts | 14 +++++++-- src/videoCallWindow/main/ipc.main.spec.ts | 38 +++++++++++++++++++++++ src/videoCallWindow/preload/index.ts | 5 +++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 70490f3ba8..0d9c163d32 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -931,13 +931,23 @@ export const startVideoCallWindowHandler = (): void => { return; } - // Resolve the target server webview: prefer the caller's own server, - // otherwise the server currently active in the main window. + // Resolve the target server webview in priority order: + // 1. the caller's own server (the conference webview, when resolvable); + // 2. the server the active call actually belongs to — authoritative, and + // avoids navigating a *different* server in a multi-workspace setup; + // 3. the server currently active in the main window (last-resort guess). let serverUrl = getServerUrlByWebContentsId(callerWebContents.id); + if (!serverUrl && activeCall?.serverWebContentsId != null) { + serverUrl = getServerUrlByWebContentsId(activeCall.serverWebContentsId); + } if (!serverUrl) { const currentView = select((state) => state.currentView); if (typeof currentView === 'object' && currentView.url) { serverUrl = currentView.url; + console.warn( + 'Video call window: open-in-main-window could not resolve the call’s origin server; falling back to the active view', + serverUrl + ); } } diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts index 9b629fb3c7..9739631a90 100644 --- a/src/videoCallWindow/main/ipc.main.spec.ts +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -668,6 +668,44 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { ); }); + it("prefers the active call's origin server over the active view when the caller is unresolved", async () => { + // Open a call from server A so `activeCall.serverWebContentsId` is set. + getServerUrlByWebContentsId.mockReturnValue('https://origin.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(50)); + + // The open-in-main-window caller (the standalone video window) does not + // resolve to a server; the active view is a *different* server. The + // handler must target the call's origin server, not the active view. + const handler = handleRegistry.get( + 'video-call-window/open-in-main-window' + ); + if (!handler) + throw new Error('open-in-main-window handler not registered'); + + getServerUrlByWebContentsId.mockImplementation((id: number) => + id === 50 ? 'https://origin.example' : undefined + ); + select.mockImplementation((sel: any) => + sel({ currentView: { url: 'https://other.example' } }) + ); + const serverWc = makeServerWc(); + getWebContentsByServerUrl.mockReturnValue(serverWc); + + await handler(makeCallerWc(999), '/channel/general'); + + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://origin.example' + ); + expect(getWebContentsByServerUrl).not.toHaveBeenCalledWith( + 'https://other.example' + ); + expect(serverWc.send).toHaveBeenCalledWith( + 'navigate-to-route', + '/channel/general' + ); + }); + it('restores the main window when minimized', async () => { getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); getWebContentsByServerUrl.mockReturnValue(makeServerWc()); diff --git a/src/videoCallWindow/preload/index.ts b/src/videoCallWindow/preload/index.ts index 51bd515608..3fe470adac 100644 --- a/src/videoCallWindow/preload/index.ts +++ b/src/videoCallWindow/preload/index.ts @@ -17,7 +17,12 @@ contextBridge.exposeInMainWorld('videoCallWindow', { openInMainWindow: (path: string) => { if (isRelativeRoute(path)) { ipcRenderer.invoke('video-call-window/open-in-main-window', path); + return; } + console.warn( + 'Video call window: openInMainWindow rejected non-relative path:', + path + ); }, // Add methods here if needed for communication with the main process requestScreenSharing: async () => { From 389c987f33ff57c4427ad83a34ce4db17ec971f7 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 24 Jun 2026 10:22:06 -0300 Subject: [PATCH 5/8] feat: add videoCallWindow.close() bridge to close the video call window The internal video-chat window is a BrowserWindow created by the main process, so the renderer's own window.close() can't close it. Expose a close() method on the window.videoCallWindow bridge that asks the main process to do it. - Preload: close: () => ipcRenderer.send('video-call-window/close') (no payload). - Main: ipcMain.on('video-call-window/close') resolves the window from the sender (with a hostWebContents fallback for the webview-guest sender) and calls win.close() when it's live. Resolving from the sender means a renderer can only close its own window. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/videoCallWindow/ipc.ts | 17 ++++++ src/videoCallWindow/main/ipc.main.spec.ts | 68 ++++++++++++++++++++++- src/videoCallWindow/preload/index.ts | 3 + 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 0d9c163d32..5f7317673f 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -901,6 +901,23 @@ export const startVideoCallWindowHandler = (): void => { event.returnValue = videoCallProviderName; }); + // Close the video call window on request from its own renderer. The + // renderer's window.close() can't close a window the main process created, so + // it asks via this fire-and-forget channel. We resolve the window from the + // sender (the webview guest's host window), so a renderer can only close its + // own window. + ipcMain.on('video-call-window/close', (event) => { + const { sender } = event; + const win = + BrowserWindow.fromWebContents(sender) ?? + (sender.hostWebContents + ? BrowserWindow.fromWebContents(sender.hostWebContents) + : null); + if (win && !win.isDestroyed()) { + win.close(); + } + }); + handle('video-call-window/screen-recording-is-permission-granted', async () => checkScreenRecordingPermission() ); diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts index 9739631a90..10e1e62ec4 100644 --- a/src/videoCallWindow/main/ipc.main.spec.ts +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -264,7 +264,10 @@ const screen = { jest.mock('electron', () => ({ app: { getAppPath: jest.fn(() => '/app') }, - BrowserWindow: jest.fn().mockImplementation(() => new FakeBrowserWindow()), + BrowserWindow: Object.assign( + jest.fn().mockImplementation(() => new FakeBrowserWindow()), + { fromWebContents: jest.fn(() => null) } + ), ipcMain: { on: jest.fn(), once: jest.fn(), @@ -762,4 +765,67 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { expect(fakeRootWindow.focus).not.toHaveBeenCalled(); }); }); + + // ------------------------------------------------------------------------- + // close: 'video-call-window/close' (ipcMain.on) closes the sender's window + // ------------------------------------------------------------------------- + describe('close', () => { + const getCloseHandler = async () => { + await loadModule(); + const electron = (await import('electron')) as any; + const call = electron.ipcMain.on.mock.calls.find( + ([channel]: [string]) => channel === 'video-call-window/close' + ); + if (!call) throw new Error('close listener not registered'); + const fromWebContents = electron.BrowserWindow + .fromWebContents as jest.Mock; + fromWebContents.mockReset(); + return { + listener: call[1] as (event: { sender: any }) => void, + fromWebContents, + }; + }; + + it('closes the window resolved from the sender', async () => { + const { listener, fromWebContents } = await getCloseHandler(); + const win = { isDestroyed: jest.fn(() => false), close: jest.fn() }; + fromWebContents.mockReturnValue(win); + + listener({ sender: { hostWebContents: null } }); + + expect(win.close).toHaveBeenCalledTimes(1); + }); + + it('falls back to the host window for a webview-guest sender', async () => { + const { listener, fromWebContents } = await getCloseHandler(); + const hostWebContents = { id: 5 }; + const win = { isDestroyed: jest.fn(() => false), close: jest.fn() }; + // Guest sender resolves to null; the hostWebContents resolves to the window. + fromWebContents.mockReturnValueOnce(null).mockReturnValueOnce(win); + + listener({ sender: { hostWebContents } }); + + expect(fromWebContents).toHaveBeenNthCalledWith(2, hostWebContents); + expect(win.close).toHaveBeenCalledTimes(1); + }); + + it('does not close an already-destroyed window', async () => { + const { listener, fromWebContents } = await getCloseHandler(); + const win = { isDestroyed: jest.fn(() => true), close: jest.fn() }; + fromWebContents.mockReturnValue(win); + + listener({ sender: { hostWebContents: null } }); + + expect(win.close).not.toHaveBeenCalled(); + }); + + it('no-ops safely when no window resolves', async () => { + const { listener, fromWebContents } = await getCloseHandler(); + fromWebContents.mockReturnValue(null); + + expect(() => + listener({ sender: { hostWebContents: null } }) + ).not.toThrow(); + }); + }); }); diff --git a/src/videoCallWindow/preload/index.ts b/src/videoCallWindow/preload/index.ts index 3fe470adac..e9ceccbdcc 100644 --- a/src/videoCallWindow/preload/index.ts +++ b/src/videoCallWindow/preload/index.ts @@ -24,6 +24,9 @@ contextBridge.exposeInMainWorld('videoCallWindow', { path ); }, + // Close the video call window. The renderer can't close a window the main + // process created, so the main process does it. + close: () => ipcRenderer.send('video-call-window/close'), // Add methods here if needed for communication with the main process requestScreenSharing: async () => { // Directly invoke the screen picker From c2575af0bbb136fbefacb229c0028bf50decb559 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 24 Jun 2026 10:55:36 -0300 Subject: [PATCH 6/8] fix: focus the existing video call window when reopening the same conference Clicking "join" again from the main window while the video call window was already open tore the window down and recreated it. When the requested conference URL matches the one already open, focus the existing window instead (restore if minimized, show, focus) and return early, leaving activeCall, provider, credentials and partition untouched. A different URL still closes + recreates as before. Track the conference URL on activeCall so the decision uses lifecycle state rather than the renderer-handshake globals. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/videoCallWindow/ipc.ts | 22 ++++++++ src/videoCallWindow/main/ipc.main.spec.ts | 64 +++++++++++++++++++++-- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 5f7317673f..5eed5274e3 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -59,6 +59,7 @@ let pendingVideoCallPartition: string | null = null; const FALLBACK_PARTITION = 'persist:jitsi-session'; type ActiveCall = { + url: string; // the conference URL this window was opened for partition: string; // 'persist:' OR the fallback — always truthy isSharedSession: boolean; // true only when a real server URL resolved serverWebContentsId: number | null; @@ -359,6 +360,26 @@ const openVideoCallWindow = async ( ): Promise => { console.log('Video call window: Open-window handler called with URL:', url); + // If a window for the same conference is already open, just focus it instead + // of tearing it down and recreating it. (`activeCall` still holds the current + // call here — it's only reassigned for the new call further below.) + if ( + videoCallWindow && + !videoCallWindow.isDestroyed() && + !isVideoCallWindowDestroying && + activeCall?.url === url + ) { + console.log( + 'Video call window: same conference already open, focusing existing window' + ); + if (videoCallWindow.isMinimized()) { + videoCallWindow.restore(); + } + videoCallWindow.show(); + videoCallWindow.focus(); + return; + } + // Store provider name and credentials videoCallProviderName = options?.providerName ?? null; videoCallCredentials = null; @@ -382,6 +403,7 @@ const openVideoCallWindow = async ( const serverUrl = getServerUrlByWebContentsId(_wc.id); const partition = serverUrl ? `persist:${serverUrl}` : FALLBACK_PARTITION; activeCall = { + url, partition, isSharedSession: Boolean(serverUrl), serverWebContentsId: serverUrl ? _wc.id : null, diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts index 10e1e62ec4..7b24b8d8bf 100644 --- a/src/videoCallWindow/main/ipc.main.spec.ts +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -244,6 +244,12 @@ class FakeBrowserWindow { show = jest.fn(); + focus = jest.fn(); + + isMinimized = jest.fn(() => false); + + restore = jest.fn(); + isFocused = jest.fn(() => true); isVisible = jest.fn(() => true); @@ -525,13 +531,14 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { // First open -> server A getServerUrlByWebContentsId.mockReturnValue('https://first.example'); const { openWindow } = await loadModule(); - await open(openWindow, makeCallerWc(1)); + await open(openWindow, makeCallerWc(1), 'https://meet.example/a'); const firstWindow = createdWindows[0]; - // Second open -> server B (fresh activeCall = B). The existing-window guard - // in openVideoCallWindow closes the first window synchronously. + // Second open -> server B (fresh activeCall = B). A DIFFERENT conference URL + // so the same-conference focus short-circuit is not taken; the existing- + // window guard in openVideoCallWindow closes the first window synchronously. getServerUrlByWebContentsId.mockReturnValue('https://second.example'); - await open(openWindow, makeCallerWc(2)); + await open(openWindow, makeCallerWc(2), 'https://meet.example/b'); expect(createdWindows).toHaveLength(2); const secondWindow = createdWindows[1]; @@ -828,4 +835,53 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { ).not.toThrow(); }); }); + + // ------------------------------------------------------------------------- + // same-conference reopen: focus the existing window instead of recreating + // ------------------------------------------------------------------------- + describe('same-conference reopen', () => { + it('focuses the existing window (no recreate, no close) for the same URL', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-x'); + expect(createdWindows).toHaveLength(1); + const win = createdWindows[0] as any; + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-x'); + + expect(createdWindows).toHaveLength(1); // not recreated + expect(win.close).not.toHaveBeenCalled(); + expect(win.show).toHaveBeenCalledTimes(1); + expect(win.focus).toHaveBeenCalledTimes(1); + }); + + it('restores first when the existing window is minimized', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-y'); + const win = createdWindows[0] as any; + win.isMinimized.mockReturnValue(true); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-y'); + + expect(win.restore).toHaveBeenCalledTimes(1); + expect(win.focus).toHaveBeenCalledTimes(1); + expect(createdWindows).toHaveLength(1); + }); + + it('recreates the window for a different conference URL', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-1'); + const first = createdWindows[0] as any; + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-2'); + + expect(createdWindows).toHaveLength(2); + expect(first.close).toHaveBeenCalled(); + }); + }); }); From 6db25a2f3873cf4aa790476034785eea5d703004 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 24 Jun 2026 11:52:37 -0300 Subject: [PATCH 7/8] fix: open external links from the video call window in the system browser The internal video-chat window didn't route external links to the system browser like the main window does, so target="_blank" / window.open links from the conference chat (which run in the webview guest, whose setWindowOpenHandler was unset) spawned a new Electron window instead. Set the guest webview's window-open handler on attach: http(s) popups return { action: 'deny' } and open via the system browser (openExternal), smb:// is denied, anything else stays in-app. Also add a will-navigate handler that sends external-scheme target="_self" navigations (mailto:, tel:, custom schemes) to the browser, while leaving http(s) self-navigations in the webview so the conference's own flows (auth redirects, etc.) keep working. Extract the shared deny/openExternal policy into a helper reused by the host window's existing handler so host and guest behave identically. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/videoCallWindow/ipc.ts | 63 ++++++++++++++++++----- src/videoCallWindow/main/ipc.main.spec.ts | 55 ++++++++++++++++++++ 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 5eed5274e3..28489b176d 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -257,6 +257,25 @@ const createInternalPickerHandler = }); }; +// Window-open policy shared by the video call window's host page and its +// conference webview: route external http(s) links (target="_blank" / +// window.open) to the system browser and deny the Electron popup, deny smb://, +// and allow anything else (in-app). Mirrors the main app window's behavior. +const handleVideoCallWindowOpen = ({ + url, +}: { + url: string; +}): { action: 'deny' } | { action: 'allow' } => { + if (url.toLowerCase().startsWith('smb://')) { + return { action: 'deny' }; + } + if (url.startsWith('http://') || url.startsWith('https://')) { + openExternal(url); + return { action: 'deny' }; + } + return { action: 'allow' }; +}; + const setupWebviewHandlers = (webContents: WebContents) => { // Track attached webviews that need handler setup const pendingWebviews: WebContents[] = []; @@ -313,6 +332,34 @@ const setupWebviewHandlers = (webContents: WebContents) => { _event: Event, webviewWebContents: WebContents ): void => { + // Route external links opened from the conference (target="_blank" / + // window.open) to the system browser instead of spawning a new Electron + // window, mirroring the main app window. + webviewWebContents.setWindowOpenHandler(handleVideoCallWindowOpen); + + // Send external-protocol target="_self" navigations (mailto:, tel:, custom + // schemes) to the browser too; http(s) self-navigations stay in the webview + // so the conference's own flows (auth redirects, etc.) keep working. + webviewWebContents.on('will-navigate', (event: Event, navUrl: string) => { + try { + const { protocol } = new URL(navUrl); + if ( + !['http:', 'https:', 'file:', 'data:', 'about:', 'blob:'].includes( + protocol + ) + ) { + event.preventDefault(); + isProtocolAllowed(navUrl).then((allowed) => { + if (allowed) { + openExternal(navUrl); + } + }); + } + } catch { + // Ignore unparseable URLs. + } + }); + if (screenPickerReady && provider) { setupDisplayMediaHandler(webviewWebContents); } else { @@ -810,19 +857,9 @@ const openVideoCallWindow = async ( url ); - webContents.setWindowOpenHandler(({ url }: { url: string }) => { - console.log('Video call window - new window requested:', url); - - if (url.toLowerCase().startsWith('smb://')) { - return { action: 'deny' }; - } - - if (url.startsWith('http://') || url.startsWith('https://')) { - openExternal(url); - return { action: 'deny' }; - } - - return { action: 'allow' }; + webContents.setWindowOpenHandler((details: { url: string }) => { + console.log('Video call window - new window requested:', details.url); + return handleVideoCallWindowOpen(details); }); webContents.on('will-navigate', (event: any, url: string) => { diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts index 7b24b8d8bf..1ce9342d03 100644 --- a/src/videoCallWindow/main/ipc.main.spec.ts +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -884,4 +884,59 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { expect(first.close).toHaveBeenCalled(); }); }); + + // ------------------------------------------------------------------------- + // external links from the conference webview -> system browser + // ------------------------------------------------------------------------- + describe('conference webview external links', () => { + const attachGuest = async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1), 'https://meet.example/room'); + + const guest = { + setWindowOpenHandler: jest.fn(), + on: jest.fn(), + session: { setDisplayMediaRequestHandler: jest.fn() }, + isDestroyed: jest.fn(() => false), + }; + // did-attach-webview is registered on the host window's webContents. + fire( + createdWindows[0].webContents.listeners, + 'did-attach-webview', + {}, + guest + ); + return guest; + }; + + it('routes http(s) popups to the system browser and denies the Electron window', async () => { + const guest = await attachGuest(); + expect(guest.setWindowOpenHandler).toHaveBeenCalledTimes(1); + const handler = guest.setWindowOpenHandler.mock.calls[0][0]; + + expect(handler({ url: 'https://example.com/page' })).toEqual({ + action: 'deny', + }); + const { openExternal } = (await import( + '../../utils/browserLauncher' + )) as any; + expect(openExternal).toHaveBeenCalledWith('https://example.com/page'); + }); + + it('allows in-app (non-external) popups', async () => { + const guest = await attachGuest(); + const handler = guest.setWindowOpenHandler.mock.calls[0][0]; + + expect(handler({ url: 'about:blank' })).toEqual({ action: 'allow' }); + }); + + it('registers a will-navigate handler on the guest webview', async () => { + const guest = await attachGuest(); + expect(guest.on).toHaveBeenCalledWith( + 'will-navigate', + expect.any(Function) + ); + }); + }); }); From 5e6f239f9eee029430b481ff792480cc158caa21 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 24 Jun 2026 14:35:37 -0300 Subject: [PATCH 8/8] fix: address review feedback on video call session sharing - media/openExternal permission branches in serverView now catch rejections and deny instead of leaving the request hanging - video call window-open policy denies popups by default, allowing only about:/blob: in-app schemes (closes javascript:/data:/file:/smb:) - install the media permission handler on the conference webview's partition session (isolated fallback only) so mic/cam requests route through handleMediaPermissionRequest instead of Electron's default - resolve the partition / set activeCall only inside the window-creating branch, after URL validation and the g.co redirect, so a bailed-out open can't leave stale state for teardown to misread - swallow the fire-and-forget open-in-main-window invoke rejection - drop no-op awaits on synchronous getNormalBounds()/getURL() - add regression tests for popup scheme policy and the fallback-session permission handler --- src/ui/main/serverView/index.ts | 40 ++++--- src/videoCallWindow/ipc.ts | 124 ++++++++++++++++------ src/videoCallWindow/main/ipc.main.spec.ts | 58 +++++++++- src/videoCallWindow/preload/index.ts | 6 +- 4 files changed, 181 insertions(+), 47 deletions(-) diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index 7d0dc5ef30..03ba8407f1 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -131,12 +131,20 @@ export const setupServerViewPermissionHandler = ( switch (permission) { case 'media': { const { mediaTypes = [] } = details as MediaAccessPermissionRequest; - await handleMediaPermissionRequest( - mediaTypes as ReadonlyArray<'audio' | 'video'>, - rootWindow, - 'recordMessage', - callback - ); + try { + await handleMediaPermissionRequest( + mediaTypes as ReadonlyArray<'audio' | 'video'>, + rootWindow, + 'recordMessage', + callback + ); + } catch (error) { + console.error( + 'Error handling media permission request in server view:', + error + ); + callback(false); + } return; } @@ -154,10 +162,18 @@ export const setupServerViewPermissionHandler = ( return; } - const allowed = await isProtocolAllowed( - (details as OpenExternalPermissionRequest).externalURL as string - ); - callback(allowed); + try { + const allowed = await isProtocolAllowed( + (details as OpenExternalPermissionRequest).externalURL as string + ); + callback(allowed); + } catch (error) { + console.error( + 'Error resolving openExternal permission in server view:', + error + ); + callback(false); + } return; } @@ -509,7 +525,7 @@ export const attachGuestWebContentsEvents = async (): Promise => { listen(SIDE_BAR_SERVER_COPY_URL, async (action) => { const guestWebContents = getWebContentsByServerUrl(action.payload); - const currentUrl = await guestWebContents?.getURL(); + const currentUrl = guestWebContents?.getURL(); clipboard.writeText(currentUrl || ''); }); @@ -579,7 +595,7 @@ export const attachGuestWebContentsEvents = async (): Promise => { label: t('sidebar.item.copyCurrentUrl'), click: async () => { const guestWebContents = getWebContentsByServerUrl(serverUrl); - const currentUrl = await guestWebContents?.getURL(); + const currentUrl = guestWebContents?.getURL(); clipboard.writeText(currentUrl || ''); }, }, diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 28489b176d..012b4435d4 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -257,23 +257,31 @@ const createInternalPickerHandler = }); }; +// Schemes a conference page legitimately opens as an in-app popup (device +// pickers, transient PDF/export blobs). Everything else that isn't external +// http(s) is denied so a compromised conference frame can't spawn an Electron +// window pointed at `javascript:`, `data:`, `file:`, etc. +const ALLOWED_POPUP_SCHEMES = ['about:', 'blob:']; + // Window-open policy shared by the video call window's host page and its // conference webview: route external http(s) links (target="_blank" / -// window.open) to the system browser and deny the Electron popup, deny smb://, -// and allow anything else (in-app). Mirrors the main app window's behavior. +// window.open) to the system browser and deny the Electron popup, allow the +// in-app popup schemes above, and deny everything else. Mirrors the main app +// window's intent while keeping the popup surface closed by default. const handleVideoCallWindowOpen = ({ url, }: { url: string; }): { action: 'deny' } | { action: 'allow' } => { - if (url.toLowerCase().startsWith('smb://')) { - return { action: 'deny' }; - } if (url.startsWith('http://') || url.startsWith('https://')) { openExternal(url); return { action: 'deny' }; } - return { action: 'allow' }; + const lower = url.toLowerCase(); + if (ALLOWED_POPUP_SCHEMES.some((scheme) => lower.startsWith(scheme))) { + return { action: 'allow' }; + } + return { action: 'deny' }; }; const setupWebviewHandlers = (webContents: WebContents) => { @@ -360,6 +368,55 @@ const setupWebviewHandlers = (webContents: WebContents) => { } }); + // Media (mic/cam) permission requests from the conference originate in the + // webview's session, NOT the host window's, so the handler must live on the + // webview partition. On a SHARED session that partition already carries the + // server view's permission handler (installed for the main webview) — leave + // it untouched so we don't clobber it. Only the isolated FALLBACK partition + // (`persist:jitsi-session`) has no handler of its own; install one there so + // the call still routes through the app's `handleMediaPermissionRequest` + // flow instead of relying on Electron's silent default-grant. + const call = activeCall; + if (!call?.isSharedSession) { + webviewWebContents.session.setPermissionRequestHandler( + async (_webContents, permission, callback, details) => { + if (permission === 'media') { + const { mediaTypes = [] } = details as MediaAccessPermissionRequest; + try { + await handleMediaPermissionRequest( + mediaTypes as ReadonlyArray<'audio' | 'video'>, + videoCallWindow, + 'initiateCall', + callback + ); + } catch (error) { + console.error( + 'Error handling media permission request in video call webview:', + error + ); + callback(false); + } + return; + } + + switch (permission) { + case 'geolocation': + case 'notifications': + case 'midiSysex': + case 'pointerLock': + case 'fullscreen': + callback(true); + return; + case 'openExternal': + callback(true); + return; + default: + callback(false); + } + } + ); + } + if (screenPickerReady && provider) { setupDisplayMediaHandler(webviewWebContents); } else { @@ -444,30 +501,6 @@ const openVideoCallWindow = async ( } } - // Always load the call webview in the originating server's partition so it - // shares the main webview's session (cookies + localStorage). When the - // server can't be resolved, fall back to an isolated jitsi-session partition. - const serverUrl = getServerUrlByWebContentsId(_wc.id); - const partition = serverUrl ? `persist:${serverUrl}` : FALLBACK_PARTITION; - activeCall = { - url, - partition, - isSharedSession: Boolean(serverUrl), - serverWebContentsId: serverUrl ? _wc.id : null, - }; - pendingVideoCallPartition = partition; // handshake global only - if (activeCall.isSharedSession) { - console.log( - 'Video call window: sharing server session via partition', - partition - ); - } else { - console.warn( - 'Video call window: could not resolve originating server; opening with isolated fallback partition', - partition - ); - } - if (isVideoCallWindowDestroying) { console.log('Waiting for video call window destruction to complete...'); await new Promise((resolve) => { @@ -518,8 +551,37 @@ const openVideoCallWindow = async ( return; } if (allowedProtocols.includes(validUrl.protocol)) { + // Resolve the partition only once we know a window will actually be created + // (URL parsed, not a g.co external redirect, protocol allowed). Setting it + // earlier would leave stale `activeCall` state behind for opens that bail + // out before `new BrowserWindow`, which a later teardown could misread. + // + // Always load the call webview in the originating server's partition so it + // shares the main webview's session (cookies + localStorage). When the + // server can't be resolved, fall back to an isolated jitsi-session partition. + const serverUrl = getServerUrlByWebContentsId(_wc.id); + const partition = serverUrl ? `persist:${serverUrl}` : FALLBACK_PARTITION; + activeCall = { + url, + partition, + isSharedSession: Boolean(serverUrl), + serverWebContentsId: serverUrl ? _wc.id : null, + }; + pendingVideoCallPartition = partition; // handshake global only + if (activeCall.isSharedSession) { + console.log( + 'Video call window: sharing server session via partition', + partition + ); + } else { + console.warn( + 'Video call window: could not resolve originating server; opening with isolated fallback partition', + partition + ); + } + const mainWindow = await getRootWindow(); - const winBounds = await mainWindow.getNormalBounds(); + const winBounds = mainWindow.getNormalBounds(); const centeredWindowPosition = { x: winBounds.x + winBounds.width / 2, diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts index 1ce9342d03..b1baca8261 100644 --- a/src/videoCallWindow/main/ipc.main.spec.ts +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -889,15 +889,20 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { // external links from the conference webview -> system browser // ------------------------------------------------------------------------- describe('conference webview external links', () => { - const attachGuest = async () => { - getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const attachGuest = async (sharedSession = true) => { + getServerUrlByWebContentsId.mockReturnValue( + sharedSession ? 'https://chat.example' : undefined + ); const { openWindow } = await loadModule(); await open(openWindow, makeCallerWc(1), 'https://meet.example/room'); const guest = { setWindowOpenHandler: jest.fn(), on: jest.fn(), - session: { setDisplayMediaRequestHandler: jest.fn() }, + session: { + setDisplayMediaRequestHandler: jest.fn(), + setPermissionRequestHandler: jest.fn(), + }, isDestroyed: jest.fn(() => false), }; // did-attach-webview is registered on the host window's webContents. @@ -929,6 +934,25 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { const handler = guest.setWindowOpenHandler.mock.calls[0][0]; expect(handler({ url: 'about:blank' })).toEqual({ action: 'allow' }); + expect(handler({ url: 'blob:https://meet.example/abc' })).toEqual({ + action: 'allow', + }); + }); + + it('denies dangerous popup schemes', async () => { + const guest = await attachGuest(); + const handler = guest.setWindowOpenHandler.mock.calls[0][0]; + + expect(handler({ url: 'javascript:alert(1)' })).toEqual({ + action: 'deny', + }); + expect(handler({ url: 'file:///etc/passwd' })).toEqual({ + action: 'deny', + }); + expect(handler({ url: 'data:text/html,' })).toEqual({ + action: 'deny', + }); + expect(handler({ url: 'smb://share/path' })).toEqual({ action: 'deny' }); }); it('registers a will-navigate handler on the guest webview', async () => { @@ -938,5 +962,33 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { expect.any(Function) ); }); + + it('installs a media permission handler on the fallback (isolated) webview session', async () => { + const { handleMediaPermissionRequest } = (await import( + '../../ui/main/mediaPermissions' + )) as any; + const guest = await attachGuest(false); + + expect(guest.session.setPermissionRequestHandler).toHaveBeenCalledTimes( + 1 + ); + const permissionHandler = + guest.session.setPermissionRequestHandler.mock.calls[0][0]; + const callback = jest.fn(); + await permissionHandler({}, 'media', callback, { + mediaTypes: ['audio', 'video'], + }); + expect(handleMediaPermissionRequest).toHaveBeenCalledWith( + ['audio', 'video'], + expect.anything(), + 'initiateCall', + callback + ); + }); + + it('does NOT install a webview permission handler on a shared session', async () => { + const guest = await attachGuest(true); + expect(guest.session.setPermissionRequestHandler).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/videoCallWindow/preload/index.ts b/src/videoCallWindow/preload/index.ts index e9ceccbdcc..f7e2a913a3 100644 --- a/src/videoCallWindow/preload/index.ts +++ b/src/videoCallWindow/preload/index.ts @@ -16,7 +16,11 @@ contextBridge.exposeInMainWorld('videoCallWindow', { // `path` is a server-relative route, e.g. "/channel/general". openInMainWindow: (path: string) => { if (isRelativeRoute(path)) { - ipcRenderer.invoke('video-call-window/open-in-main-window', path); + ipcRenderer + .invoke('video-call-window/open-in-main-window', path) + .catch((error) => + console.warn('Video call window: open-in-main-window failed:', error) + ); return; } console.warn(