Skip to content
2 changes: 2 additions & 0 deletions src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -45,6 +46,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 };
Expand Down
2 changes: 2 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ const start = async (): Promise<void> => {
await invoke('server-view/ready');

listenToTelephonyRequests();
listenToNavigateToRouteRequests();

console.log('[Rocket.Chat Desktop] waiting for RocketChatDesktop.onReady');
RocketChatDesktop.onReady(() => {
Expand Down
37 changes: 37 additions & 0 deletions src/screenSharing/serverViewScreenSharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions src/servers/preload/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getInternalVideoChatWindowEnabled,
openInternalVideoChatWindow,
} from './internalVideoChatWindow';
import { onNavigateToRoute } from './navigateToRoute';
import { openInBrowser } from './openInBrowser';
import { reloadServer } from './reloadServer';
import {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -108,4 +110,5 @@ export const RocketChatDesktop: Window['RocketChatDesktop'] = {
reloadServer,
getE2ePdfPreviewSizeLimit,
onTelephonyCallRequested,
onNavigateToRoute,
};
65 changes: 65 additions & 0 deletions src/servers/preload/navigateToRoute.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
type IpcListener = (event: unknown, path: string) => void;

const ipcListeners = new Map<string, IpcListener>();
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();
});
});
36 changes: 36 additions & 0 deletions src/servers/preload/navigateToRoute.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
};
6 changes: 5 additions & 1 deletion src/ui/main/menuBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
114 changes: 66 additions & 48 deletions src/ui/main/serverView/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type {
MediaAccessPermissionRequest,
MenuItemConstructorOptions,
OpenExternalPermissionRequest,
Session,
UploadFile,
UploadRawData,
WebContents,
Expand Down Expand Up @@ -122,6 +121,69 @@ 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;
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

case 'geolocation':
case 'notifications':
case 'midiSysex':
case 'pointerLock':
case 'fullscreen':
callback(true);
return;

case 'openExternal': {
if (!(details as OpenExternalPermissionRequest).externalURL) {
callback(false);
return;
}

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;
}

default:
callback(false);
}
}
);
};

const initializeServerWebContentsAfterReady = (
_serverUrl: string,
guestWebContents: WebContents,
Expand Down Expand Up @@ -402,48 +464,6 @@ export const attachGuestWebContentsEvents = async (): Promise<void> => {
);
};

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
Expand All @@ -454,9 +474,7 @@ export const attachGuestWebContentsEvents = async (): Promise<void> => {
rootWindow
);

guestWebContents.session.setPermissionRequestHandler(
handlePermissionRequest
);
setupServerViewPermissionHandler(guestWebContents, rootWindow);

setupServerViewDisplayMedia(guestWebContents);

Expand Down Expand Up @@ -507,7 +525,7 @@ export const attachGuestWebContentsEvents = async (): Promise<void> => {

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 || '');
});

Expand Down Expand Up @@ -577,7 +595,7 @@ export const attachGuestWebContentsEvents = async (): Promise<void> => {
label: t('sidebar.item.copyCurrentUrl'),
click: async () => {
const guestWebContents = getWebContentsByServerUrl(serverUrl);
const currentUrl = await guestWebContents?.getURL();
const currentUrl = guestWebContents?.getURL();
clipboard.writeText(currentUrl || '');
},
},
Expand Down
Loading
Loading