diff --git a/rollup.config.mjs b/rollup.config.mjs
index 341eb922b4..671a0c50c5 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -267,6 +267,40 @@ export default [
},
],
},
+ {
+ external: [
+ ...builtinModules,
+ ...Object.keys(appManifest.dependencies),
+ ...Object.keys(appManifest.devDependencies),
+ ].filter((moduleName) => moduleName !== '@bugsnag/js'),
+ input: 'src/screenSharing/screen-picker-window.tsx',
+ preserveEntrySignatures: 'strict',
+ plugins: [
+ json(),
+ replace({
+ 'process.env.NODE_ENV': JSON.stringify(NODE_ENV),
+ 'preventAssignment': true,
+ }),
+ babel({
+ babelHelpers: 'bundled',
+ extensions,
+ }),
+ nodeResolve({
+ browser: true,
+ extensions,
+ }),
+ commonjs(),
+ run(),
+ ],
+ output: [
+ {
+ dir: 'app',
+ format: 'cjs',
+ sourcemap: 'inline',
+ interop: 'auto',
+ },
+ ],
+ },
{
external: [
...builtinModules,
diff --git a/src/public/screen-picker-window.html b/src/public/screen-picker-window.html
new file mode 100644
index 0000000000..d6493fc056
--- /dev/null
+++ b/src/public/screen-picker-window.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Screen Sharing - Rocket.Chat
+
+
+
+
+
+
+
+
+
+
diff --git a/src/screenSharing/ScreenSharingRequestTracker.ts b/src/screenSharing/ScreenSharingRequestTracker.ts
index ba2a7d4dd3..0798d06e59 100644
--- a/src/screenSharing/ScreenSharingRequestTracker.ts
+++ b/src/screenSharing/ScreenSharingRequestTracker.ts
@@ -5,6 +5,23 @@ import type { DisplayMediaCallback } from './screenPicker/types';
const DEFAULT_TIMEOUT = 60000;
+type CreateRequestOptions = {
+ isStillValid?: () => boolean;
+ onDone?: () => void;
+};
+
+type ScreenSharingRequestHandle = {
+ cancel: () => void;
+};
+
+type QueueEntry = {
+ requestId: string;
+ cb: DisplayMediaCallback;
+ sendOpenPicker: () => void;
+ options?: CreateRequestOptions;
+ settled: boolean;
+};
+
export class ScreenSharingRequestTracker {
private activeListener:
| ((event: Event, sourceId: string | null) => void)
@@ -12,10 +29,14 @@ export class ScreenSharingRequestTracker {
private activeRequestId: string | null = null;
+ private activeEntry: QueueEntry | null = null;
+
private timeout: NodeJS.Timeout | null = null;
private isPending = false;
+ private queue: QueueEntry[] = [];
+
constructor(
private readonly responseChannel: string,
private readonly label: string,
@@ -33,8 +54,27 @@ export class ScreenSharingRequestTracker {
this.timeout = null;
}
+ const active = this.activeEntry;
this.activeRequestId = null;
+ this.activeEntry = null;
this.isPending = false;
+
+ if (active && !active.settled) {
+ active.settled = true;
+ active.cb(null);
+ active.options?.onDone?.();
+ }
+
+ const drained = this.queue;
+ this.queue = [];
+ drained.forEach((entry) => {
+ if (entry.settled) {
+ return;
+ }
+ entry.settled = true;
+ entry.cb(null);
+ entry.options?.onDone?.();
+ });
}
private removeListenerOnly(): void {
@@ -51,6 +91,7 @@ export class ScreenSharingRequestTracker {
private markComplete(): void {
this.activeRequestId = null;
+ this.activeEntry = null;
this.isPending = false;
}
@@ -58,36 +99,60 @@ export class ScreenSharingRequestTracker {
return this.isPending;
}
- createRequest(cb: DisplayMediaCallback, sendOpenPicker: () => void): void {
- if (this.isPending) {
- console.warn(`${this.label}: request already pending, ignoring`);
- cb({ video: false } as any);
+ private finishActive(entry: QueueEntry): void {
+ this.markComplete();
+ entry.settled = true;
+ entry.options?.onDone?.();
+ this.processNext();
+ }
+
+ private cancelActiveEntry(entry: QueueEntry): void {
+ this.removeListenerOnly();
+ this.markComplete();
+ entry.settled = true;
+ entry.cb(null);
+ entry.options?.onDone?.();
+ }
+
+ private processNext(): void {
+ const entry = this.queue.shift();
+ if (!entry) {
return;
}
- this.cleanup();
+ if (entry.options?.isStillValid && !entry.options.isStillValid()) {
+ entry.settled = true;
+ entry.cb(null);
+ entry.options?.onDone?.();
+ this.processNext();
+ return;
+ }
- const requestId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
+ this.startRequest(entry);
+ }
+
+ private startRequest(entry: QueueEntry): void {
+ this.removeListenerOnly();
+
+ const { requestId } = entry;
this.activeRequestId = requestId;
+ this.activeEntry = entry;
this.isPending = true;
- let callbackInvoked = false;
-
const listener = async (_event: Event, sourceId: string | null) => {
if (this.activeRequestId !== requestId) {
return;
}
- if (callbackInvoked) {
+ if (entry.settled) {
return;
}
- callbackInvoked = true;
this.removeListenerOnly();
- this.markComplete();
if (!sourceId) {
- cb({ video: false } as any);
+ entry.cb(null);
+ this.finishActive(entry);
return;
}
@@ -96,6 +161,10 @@ export class ScreenSharingRequestTracker {
types: ['window', 'screen'],
});
+ if (entry.settled) {
+ return;
+ }
+
const selectedSource = sources.find((s) => s.id === sourceId);
if (!selectedSource) {
@@ -103,14 +172,21 @@ export class ScreenSharingRequestTracker {
`${this.label}: selected source no longer available:`,
sourceId
);
- cb({ video: false } as any);
+ entry.cb(null);
+ this.finishActive(entry);
return;
}
- cb({ video: selectedSource });
+ entry.cb({ video: selectedSource });
+ this.finishActive(entry);
} catch (error) {
+ if (entry.settled) {
+ return;
+ }
+
console.error(`${this.label}: error validating source:`, error);
- cb({ video: false } as any);
+ entry.cb(null);
+ this.finishActive(entry);
}
};
@@ -121,18 +197,78 @@ export class ScreenSharingRequestTracker {
return;
}
- if (callbackInvoked) {
+ if (entry.settled) {
return;
}
- callbackInvoked = true;
console.warn(`${this.label}: request timed out, cleaning up`);
this.removeListenerOnly();
- this.markComplete();
- cb({ video: false } as any);
+ entry.cb(null);
+ this.finishActive(entry);
}, this.timeoutMs);
ipcMain.once(this.responseChannel, listener);
- sendOpenPicker();
+ entry.sendOpenPicker();
+ }
+
+ createRequest(
+ cb: DisplayMediaCallback,
+ sendOpenPicker: () => void,
+ options?: CreateRequestOptions
+ ): ScreenSharingRequestHandle {
+ const requestId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
+ const entry: QueueEntry = {
+ requestId,
+ cb,
+ sendOpenPicker,
+ options,
+ settled: false,
+ };
+
+ if (this.isPending) {
+ this.queue.push(entry);
+ } else {
+ this.cleanup();
+ this.startRequest(entry);
+ }
+
+ return {
+ cancel: () => {
+ if (entry.settled) {
+ return;
+ }
+
+ if (this.activeEntry === entry) {
+ this.cancelActiveEntry(entry);
+ this.processNext();
+ return;
+ }
+
+ const index = this.queue.indexOf(entry);
+ if (index !== -1) {
+ this.queue.splice(index, 1);
+ entry.settled = true;
+ entry.cb(null);
+ entry.options?.onDone?.();
+ }
+ },
+ };
+ }
+
+ cancelAll(): void {
+ if (this.activeEntry && !this.activeEntry.settled) {
+ this.cancelActiveEntry(this.activeEntry);
+ }
+
+ const drained = this.queue;
+ this.queue = [];
+ drained.forEach((entry) => {
+ if (entry.settled) {
+ return;
+ }
+ entry.settled = true;
+ entry.cb(null);
+ entry.options?.onDone?.();
+ });
}
}
diff --git a/src/screenSharing/main/ScreenSharingRequestTracker.main.spec.ts b/src/screenSharing/main/ScreenSharingRequestTracker.main.spec.ts
index 2d9529d687..ca53cdbdc2 100644
--- a/src/screenSharing/main/ScreenSharingRequestTracker.main.spec.ts
+++ b/src/screenSharing/main/ScreenSharingRequestTracker.main.spec.ts
@@ -79,7 +79,7 @@ describe('ScreenSharingRequestTracker', () => {
expect(tracker.pending).toBe(false);
});
- it('ignores a duplicate request while one is pending and invokes cb with no video', () => {
+ it('queues a second request while one is pending instead of rejecting it', () => {
const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
const sendOpenPicker = jest.fn();
@@ -89,12 +89,259 @@ describe('ScreenSharingRequestTracker', () => {
const secondSendOpenPicker = jest.fn();
tracker.createRequest(secondCb, secondSendOpenPicker);
- expect(secondCb).toHaveBeenCalledWith({ video: false });
+ expect(secondCb).not.toHaveBeenCalled();
expect(secondSendOpenPicker).not.toHaveBeenCalled();
// still only one listener registered (from the first request)
expect(onceMock).toHaveBeenCalledTimes(1);
expect(tracker.pending).toBe(true);
});
+
+ it('starts the queued request once the active one resolves', async () => {
+ getSourcesMock.mockResolvedValue([makeSource('screen:0')]);
+
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ tracker.createRequest(jest.fn(), jest.fn());
+
+ const secondCb = jest.fn();
+ const secondSendOpenPicker = jest.fn();
+ tracker.createRequest(secondCb, secondSendOpenPicker);
+
+ const firstListener = getRegisteredListener();
+ firstListener(fakeEvent, 'screen:0');
+ await flushPromises();
+
+ expect(onceMock).toHaveBeenCalledTimes(2);
+ expect(secondSendOpenPicker).toHaveBeenCalledTimes(1);
+ expect(tracker.pending).toBe(true);
+ expect(secondCb).not.toHaveBeenCalled();
+ });
+
+ it('starts queued requests in FIFO order', async () => {
+ getSourcesMock.mockResolvedValue([makeSource('screen:0')]);
+
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ const order: string[] = [];
+
+ tracker.createRequest(jest.fn(), () => order.push('first'));
+ tracker.createRequest(jest.fn(), () => order.push('second'));
+ tracker.createRequest(jest.fn(), () => order.push('third'));
+
+ let listener = getRegisteredListener();
+ listener(fakeEvent, 'screen:0');
+ await flushPromises();
+
+ listener = getRegisteredListener();
+ listener(fakeEvent, 'screen:0');
+ await flushPromises();
+
+ expect(order).toEqual(['first', 'second', 'third']);
+ });
+
+ it('starts the next queued request once the active one times out', () => {
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL, 1000);
+ const firstCb = jest.fn();
+ tracker.createRequest(firstCb, jest.fn());
+
+ const secondCb = jest.fn();
+ const secondSendOpenPicker = jest.fn();
+ tracker.createRequest(secondCb, secondSendOpenPicker);
+
+ jest.advanceTimersByTime(1000);
+
+ expect(firstCb).toHaveBeenCalledWith(null);
+ expect(secondSendOpenPicker).toHaveBeenCalledTimes(1);
+ expect(secondCb).not.toHaveBeenCalled();
+ expect(tracker.pending).toBe(true);
+
+ // the queued request's own timeout starts fresh when it becomes active
+ jest.advanceTimersByTime(999);
+ expect(secondCb).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1);
+ expect(secondCb).toHaveBeenCalledWith(null);
+ });
+
+ it('skips a queued request whose isStillValid returns false and starts the next one', async () => {
+ getSourcesMock.mockResolvedValue([makeSource('screen:0')]);
+
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ tracker.createRequest(jest.fn(), jest.fn());
+
+ const staleCb = jest.fn();
+ const staleOnDone = jest.fn();
+ tracker.createRequest(staleCb, jest.fn(), {
+ isStillValid: () => false,
+ onDone: staleOnDone,
+ });
+
+ const thirdCb = jest.fn();
+ const thirdSendOpenPicker = jest.fn();
+ tracker.createRequest(thirdCb, thirdSendOpenPicker);
+
+ const firstListener = getRegisteredListener();
+ firstListener(fakeEvent, 'screen:0');
+ await flushPromises();
+
+ expect(staleCb).toHaveBeenCalledWith(null);
+ expect(staleOnDone).toHaveBeenCalledTimes(1);
+ expect(thirdSendOpenPicker).toHaveBeenCalledTimes(1);
+ expect(thirdCb).not.toHaveBeenCalled();
+ });
+
+ it('calls onDone when a request resolves, times out, or is cancelled', async () => {
+ getSourcesMock.mockResolvedValue([makeSource('screen:0')]);
+
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL, 1000);
+
+ const resolvedOnDone = jest.fn();
+ tracker.createRequest(jest.fn(), jest.fn(), {
+ onDone: resolvedOnDone,
+ });
+ const listener = getRegisteredListener();
+ listener(fakeEvent, 'screen:0');
+ await flushPromises();
+ expect(resolvedOnDone).toHaveBeenCalledTimes(1);
+
+ const timeoutOnDone = jest.fn();
+ tracker.createRequest(jest.fn(), jest.fn(), { onDone: timeoutOnDone });
+ jest.advanceTimersByTime(1000);
+ expect(timeoutOnDone).toHaveBeenCalledTimes(1);
+
+ const cancelOnDone = jest.fn();
+ const handle = tracker.createRequest(jest.fn(), jest.fn(), {
+ onDone: cancelOnDone,
+ });
+ handle.cancel();
+ expect(cancelOnDone).toHaveBeenCalledTimes(1);
+ });
+
+ it('queues a request that arrives while the active one is validating its source, and starts it once validation finishes', async () => {
+ let resolveSources: (
+ sources: Awaited>
+ ) => void;
+ getSourcesMock.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSources = resolve;
+ })
+ );
+
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ const firstCb = jest.fn();
+ tracker.createRequest(firstCb, jest.fn());
+
+ const listener = getRegisteredListener();
+ listener(fakeEvent, 'screen:0');
+ await flushPromises();
+
+ expect(tracker.pending).toBe(true);
+ expect(firstCb).not.toHaveBeenCalled();
+
+ const secondCb = jest.fn();
+ const secondSendOpenPicker = jest.fn();
+ tracker.createRequest(secondCb, secondSendOpenPicker);
+
+ expect(secondSendOpenPicker).not.toHaveBeenCalled();
+ expect(secondCb).not.toHaveBeenCalled();
+ expect(onceMock).toHaveBeenCalledTimes(1);
+
+ resolveSources!([makeSource('screen:0')]);
+ await flushPromises();
+
+ expect(firstCb).toHaveBeenCalledWith({ video: makeSource('screen:0') });
+ expect(secondSendOpenPicker).toHaveBeenCalledTimes(1);
+ expect(secondCb).not.toHaveBeenCalled();
+ expect(tracker.pending).toBe(true);
+ });
+ });
+
+ describe('cancel', () => {
+ it('cancels the active request, invoking cb and onDone, and starts the next queued request', () => {
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ const firstCb = jest.fn();
+ const firstOnDone = jest.fn();
+ const handle = tracker.createRequest(firstCb, jest.fn(), {
+ onDone: firstOnDone,
+ });
+
+ const secondCb = jest.fn();
+ const secondSendOpenPicker = jest.fn();
+ tracker.createRequest(secondCb, secondSendOpenPicker);
+
+ handle.cancel();
+
+ expect(firstCb).toHaveBeenCalledWith(null);
+ expect(firstOnDone).toHaveBeenCalledTimes(1);
+ expect(secondSendOpenPicker).toHaveBeenCalledTimes(1);
+ expect(secondCb).not.toHaveBeenCalled();
+ expect(tracker.pending).toBe(true);
+ });
+
+ it('removes a queued request without affecting the active request', () => {
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ const firstCb = jest.fn();
+ tracker.createRequest(firstCb, jest.fn());
+
+ const queuedOnDone = jest.fn();
+ const handle = tracker.createRequest(jest.fn(), jest.fn(), {
+ onDone: queuedOnDone,
+ });
+ handle.cancel();
+
+ expect(queuedOnDone).toHaveBeenCalledTimes(1);
+ expect(firstCb).not.toHaveBeenCalled();
+ expect(tracker.pending).toBe(true);
+ expect(onceMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('is a no-op when called after the request has already settled', async () => {
+ getSourcesMock.mockResolvedValue([makeSource('screen:0')]);
+
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ const cb = jest.fn();
+ const handle = tracker.createRequest(cb, jest.fn());
+
+ const listener = getRegisteredListener();
+ listener(fakeEvent, 'screen:0');
+ await flushPromises();
+ expect(cb).toHaveBeenCalledTimes(1);
+
+ handle.cancel();
+ handle.cancel();
+
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
+
+ it('cancelling while source validation is in flight settles once with no video, and the resolved validation does not settle again', async () => {
+ let resolveSources: (
+ sources: Awaited>
+ ) => void;
+ getSourcesMock.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSources = resolve;
+ })
+ );
+
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ const cb = jest.fn();
+ const handle = tracker.createRequest(cb, jest.fn());
+
+ const listener = getRegisteredListener();
+ listener(fakeEvent, 'screen:0');
+ await flushPromises();
+
+ expect(cb).not.toHaveBeenCalled();
+
+ handle.cancel();
+ expect(cb).toHaveBeenCalledTimes(1);
+ expect(cb).toHaveBeenCalledWith(null);
+
+ resolveSources!([makeSource('screen:0')]);
+ await flushPromises();
+
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
});
describe('response listener', () => {
@@ -130,7 +377,7 @@ describe('ScreenSharingRequestTracker', () => {
await flushPromises();
expect(getSourcesMock).not.toHaveBeenCalled();
- expect(cb).toHaveBeenCalledWith({ video: false });
+ expect(cb).toHaveBeenCalledWith(null);
expect(tracker.pending).toBe(false);
});
@@ -145,7 +392,7 @@ describe('ScreenSharingRequestTracker', () => {
listener(fakeEvent, 'screen:gone');
await flushPromises();
- expect(cb).toHaveBeenCalledWith({ video: false });
+ expect(cb).toHaveBeenCalledWith(null);
expect(tracker.pending).toBe(false);
});
@@ -160,7 +407,7 @@ describe('ScreenSharingRequestTracker', () => {
listener(fakeEvent, 'screen:0');
await flushPromises();
- expect(cb).toHaveBeenCalledWith({ video: false });
+ expect(cb).toHaveBeenCalledWith(null);
expect(tracker.pending).toBe(false);
});
@@ -172,13 +419,15 @@ describe('ScreenSharingRequestTracker', () => {
tracker.createRequest(cb, jest.fn());
const listener = getRegisteredListener();
- // cleanup clears activeRequestId, so listener early-returns
+ // cleanup settles the active entry with no video and clears activeRequestId,
+ // so the stale listener call below early-returns without settling again
tracker.cleanup();
+ expect(cb).toHaveBeenCalledTimes(1);
listener(fakeEvent, 'screen:0');
await flushPromises();
- expect(cb).not.toHaveBeenCalled();
+ expect(cb).toHaveBeenCalledTimes(1);
expect(getSourcesMock).not.toHaveBeenCalled();
});
@@ -219,7 +468,7 @@ describe('ScreenSharingRequestTracker', () => {
jest.advanceTimersByTime(1000);
- expect(cb).toHaveBeenCalledWith({ video: false });
+ expect(cb).toHaveBeenCalledWith(null);
expect(tracker.pending).toBe(false);
expect(removeListenerMock).toHaveBeenCalledWith(
CHANNEL,
@@ -243,10 +492,13 @@ describe('ScreenSharingRequestTracker', () => {
const cb = jest.fn();
tracker.createRequest(cb, jest.fn());
+ // cleanup() settles the active entry synchronously with no video
tracker.cleanup();
+ expect(cb).toHaveBeenCalledTimes(1);
+
jest.advanceTimersByTime(1000);
- expect(cb).not.toHaveBeenCalled();
+ expect(cb).toHaveBeenCalledTimes(1);
});
it('does not double-resolve when the timeout fires after a response was handled', async () => {
@@ -277,7 +529,7 @@ describe('ScreenSharingRequestTracker', () => {
expect(cb).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
- expect(cb).toHaveBeenCalledWith({ video: false });
+ expect(cb).toHaveBeenCalledWith(null);
});
});
@@ -330,5 +582,87 @@ describe('ScreenSharingRequestTracker', () => {
expect(tracker.pending).toBe(true);
expect(onceMock).toHaveBeenCalledTimes(2);
});
+
+ it('drains queued requests, invoking cb and onDone for each', () => {
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ tracker.createRequest(jest.fn(), jest.fn());
+
+ const queuedCb = jest.fn();
+ const queuedOnDone = jest.fn();
+ tracker.createRequest(jest.fn(), jest.fn(), { onDone: queuedOnDone });
+ tracker.createRequest(queuedCb, jest.fn());
+
+ tracker.cleanup();
+
+ expect(queuedCb).toHaveBeenCalledWith(null);
+ expect(queuedOnDone).toHaveBeenCalledTimes(1);
+ expect(tracker.pending).toBe(false);
+ });
+
+ it('settles the active entry with no video and calls its onDone', () => {
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ const cb = jest.fn();
+ const onDone = jest.fn();
+ tracker.createRequest(cb, jest.fn(), { onDone });
+
+ tracker.cleanup();
+
+ expect(cb).toHaveBeenCalledWith(null);
+ expect(onDone).toHaveBeenCalledTimes(1);
+ expect(tracker.pending).toBe(false);
+ });
+ });
+
+ describe('cancelAll', () => {
+ it('settles the active entry with no video and calls its onDone', () => {
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ const cb = jest.fn();
+ const onDone = jest.fn();
+ tracker.createRequest(cb, jest.fn(), { onDone });
+
+ tracker.cancelAll();
+
+ expect(cb).toHaveBeenCalledWith(null);
+ expect(onDone).toHaveBeenCalledTimes(1);
+ expect(tracker.pending).toBe(false);
+ });
+
+ it('drains queued entries, invoking cb and onDone for each', () => {
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ tracker.createRequest(jest.fn(), jest.fn());
+
+ const queuedCb = jest.fn();
+ const queuedOnDone = jest.fn();
+ tracker.createRequest(jest.fn(), jest.fn(), { onDone: queuedOnDone });
+ tracker.createRequest(queuedCb, jest.fn());
+
+ tracker.cancelAll();
+
+ expect(queuedCb).toHaveBeenCalledWith(null);
+ expect(queuedOnDone).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not start the next queued request', () => {
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+ tracker.createRequest(jest.fn(), jest.fn());
+
+ const queuedCb = jest.fn();
+ const queuedSendOpenPicker = jest.fn();
+ tracker.createRequest(queuedCb, queuedSendOpenPicker);
+
+ tracker.cancelAll();
+
+ expect(onceMock).toHaveBeenCalledTimes(1);
+ expect(queuedSendOpenPicker).not.toHaveBeenCalled();
+ expect(tracker.pending).toBe(false);
+ });
+
+ it('is a no-op when idle', () => {
+ const tracker = new ScreenSharingRequestTracker(CHANNEL, LABEL);
+
+ expect(() => tracker.cancelAll()).not.toThrow();
+ expect(removeListenerMock).not.toHaveBeenCalled();
+ expect(tracker.pending).toBe(false);
+ });
});
});
diff --git a/src/screenSharing/main/resolveStandaloneOriginWindow.main.spec.ts b/src/screenSharing/main/resolveStandaloneOriginWindow.main.spec.ts
new file mode 100644
index 0000000000..1b64d52477
--- /dev/null
+++ b/src/screenSharing/main/resolveStandaloneOriginWindow.main.spec.ts
@@ -0,0 +1,90 @@
+import type { BrowserWindow as BrowserWindowType, WebContents } from 'electron';
+import { BrowserWindow, webContents } from 'electron';
+
+import { resolveStandaloneOriginWindow } from '../serverViewScreenSharing';
+
+jest.mock('electron', () => ({
+ BrowserWindow: {
+ fromWebContents: jest.fn(),
+ },
+ webContents: {
+ fromFrame: jest.fn(),
+ },
+}));
+
+jest.mock('../../ipc/main', () => ({
+ handle: jest.fn(),
+}));
+
+jest.mock('../../navigation/main', () => ({
+ isProtocolAllowed: jest.fn(() => Promise.resolve(true)),
+}));
+
+jest.mock('../../ui/main/rootWindow', () => ({
+ getRootWindow: jest.fn(() => Promise.resolve(null)),
+}));
+
+jest.mock('../../utils/browserLauncher', () => ({
+ openExternal: jest.fn(),
+}));
+
+jest.mock('../screenRecordingPermission', () => ({
+ checkScreenRecordingPermission: jest.fn(() => Promise.resolve(true)),
+}));
+
+jest.mock('../desktopCapturerCache', () => ({
+ prewarmDesktopCapturerCache: jest.fn(),
+}));
+
+const fromFrameMock = webContents.fromFrame as jest.MockedFunction<
+ typeof webContents.fromFrame
+>;
+const fromWebContentsMock =
+ BrowserWindow.fromWebContents as jest.MockedFunction<
+ typeof BrowserWindow.fromWebContents
+ >;
+
+describe('resolveStandaloneOriginWindow', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns null when frame is null', () => {
+ expect(resolveStandaloneOriginWindow(null)).toBeNull();
+ expect(fromFrameMock).not.toHaveBeenCalled();
+ });
+
+ it('returns null when frame is undefined', () => {
+ expect(resolveStandaloneOriginWindow(undefined)).toBeNull();
+ expect(fromFrameMock).not.toHaveBeenCalled();
+ });
+
+ it('returns null when webContents.fromFrame finds nothing', () => {
+ fromFrameMock.mockReturnValue(undefined);
+
+ expect(resolveStandaloneOriginWindow({} as any)).toBeNull();
+ expect(fromWebContentsMock).not.toHaveBeenCalled();
+ });
+
+ it('returns null when the origin is a webview guest (has hostWebContents)', () => {
+ const guestWebContents = {
+ hostWebContents: {} as WebContents,
+ } as WebContents;
+ fromFrameMock.mockReturnValue(guestWebContents);
+
+ expect(resolveStandaloneOriginWindow({} as any)).toBeNull();
+ expect(fromWebContentsMock).not.toHaveBeenCalled();
+ });
+
+ it('returns the owning BrowserWindow for a standalone window origin', () => {
+ const standaloneWebContents = {
+ hostWebContents: null,
+ } as WebContents;
+ const expectedWindow = {} as BrowserWindowType;
+ fromFrameMock.mockReturnValue(standaloneWebContents);
+ fromWebContentsMock.mockReturnValue(expectedWindow);
+
+ expect(resolveStandaloneOriginWindow({} as any)).toBe(expectedWindow);
+ expect(fromWebContentsMock).toHaveBeenCalledWith(standaloneWebContents);
+ });
+});
diff --git a/src/screenSharing/popoutPickerRequest.ts b/src/screenSharing/popoutPickerRequest.ts
new file mode 100644
index 0000000000..36f98797bc
--- /dev/null
+++ b/src/screenSharing/popoutPickerRequest.ts
@@ -0,0 +1,42 @@
+import type { BrowserWindow } from 'electron';
+
+import type { ScreenSharingRequestTracker } from './ScreenSharingRequestTracker';
+import type { DisplayMediaCallback } from './screenPicker/types';
+import type { ScreenPickerWindowChannels } from './screenPickerWindow';
+import { openScreenPickerWindow } from './screenPickerWindow';
+
+/**
+ * Opens a standalone picker window parented to `originWindow` and routes the
+ * display-media request through `tracker`, closing the picker window once the
+ * request settles (response, timeout, or cancellation via `originWindow`
+ * closing first).
+ */
+export const requestViaPickerWindow = (
+ tracker: ScreenSharingRequestTracker,
+ originWindow: BrowserWindow,
+ channels: ScreenPickerWindowChannels,
+ cb: DisplayMediaCallback
+): void => {
+ let pickerWindow: BrowserWindow | null = null;
+ let handle: { cancel: () => void } | null = null;
+
+ handle = tracker.createRequest(
+ cb,
+ () => {
+ pickerWindow = openScreenPickerWindow(originWindow, channels);
+ pickerWindow.on('closed', () => {
+ pickerWindow = null;
+ handle?.cancel();
+ });
+ },
+ {
+ isStillValid: () => !originWindow.isDestroyed(),
+ onDone: () => {
+ if (pickerWindow && !pickerWindow.isDestroyed()) {
+ pickerWindow.close();
+ }
+ pickerWindow = null;
+ },
+ }
+ );
+};
diff --git a/src/screenSharing/screen-picker-window.tsx b/src/screenSharing/screen-picker-window.tsx
new file mode 100644
index 0000000000..8dfabb3059
--- /dev/null
+++ b/src/screenSharing/screen-picker-window.tsx
@@ -0,0 +1,112 @@
+import { PaletteStyleTag } from '@rocket.chat/fuselage';
+import type { Themes } from '@rocket.chat/fuselage/dist/components/PaletteStyleTag/types/themes';
+import i18next from 'i18next';
+import { useState, useEffect } from 'react';
+import { createRoot } from 'react-dom/client';
+import { initReactI18next, I18nextProvider } from 'react-i18next';
+
+import { fallbackLng, interpolation } from '../i18n/common';
+import '../logging/preload';
+import resources from '../i18n/resources';
+import { ScreenSharePicker } from './screenSharePicker';
+
+const searchParams = new URLSearchParams(window.location.search);
+const responseChannel =
+ searchParams.get('response') ?? 'screen-picker/source-responded';
+const permissionChannel =
+ searchParams.get('permission') ??
+ 'screen-picker/screen-recording-is-permission-granted';
+const openUrlChannel = searchParams.get('openUrl') ?? 'screen-picker/open-url';
+
+const useSystemTheme = (): Themes => {
+ const [theme, setTheme] = useState(() =>
+ window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+ );
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ const handleChange = (e: MediaQueryListEvent) => {
+ setTheme(e.matches ? 'dark' : 'light');
+ };
+
+ mediaQuery.addEventListener('change', handleChange);
+ return () => mediaQuery.removeEventListener('change', handleChange);
+ }, []);
+
+ return theme;
+};
+
+const ThemedScreenPickerWindow = () => {
+ const theme = useSystemTheme();
+
+ return (
+ <>
+
+ setVisible(true)}
+ />
+ >
+ );
+};
+
+const detectLocale = (): string => {
+ const browserLang = navigator.language;
+ if (!browserLang) return fallbackLng;
+
+ // Try exact match first (e.g., "pt-BR" → "pt-BR")
+ if (browserLang in resources) return browserLang;
+
+ // Try language code only (e.g., "fr-FR" → "fr")
+ const [langCode] = browserLang.split('-');
+ if (langCode && langCode in resources) return langCode;
+
+ return fallbackLng;
+};
+
+const setupI18n = async () => {
+ const lng = detectLocale();
+
+ const resourceBundles: Record = {
+ [fallbackLng]: {
+ translation: await resources[fallbackLng](),
+ },
+ };
+
+ // Load detected locale if different from fallback
+ if (lng !== fallbackLng && lng in resources) {
+ resourceBundles[lng] = {
+ translation: await resources[lng as keyof typeof resources](),
+ };
+ }
+
+ await i18next.use(initReactI18next).init({
+ lng,
+ fallbackLng,
+ resources: resourceBundles,
+ interpolation,
+ initImmediate: true,
+ });
+};
+
+const start = async () => {
+ await setupI18n();
+
+ const rootElement = document.getElementById('root');
+
+ if (!rootElement) {
+ throw new Error('Root element not found');
+ }
+
+ const root = createRoot(rootElement);
+ root.render(
+
+
+
+ );
+};
+
+start().catch(console.error);
diff --git a/src/screenSharing/screenPicker/__tests__/InternalPickerProvider.spec.ts b/src/screenSharing/screenPicker/__tests__/InternalPickerProvider.spec.ts
index 0c52c3d922..3cc9f248a3 100644
--- a/src/screenSharing/screenPicker/__tests__/InternalPickerProvider.spec.ts
+++ b/src/screenSharing/screenPicker/__tests__/InternalPickerProvider.spec.ts
@@ -13,18 +13,18 @@ describe('InternalPickerProvider', () => {
const callback = jest.fn() as jest.MockedFunction;
provider.handleDisplayMediaRequest(callback);
- expect(callback).toHaveBeenCalledWith({ video: false });
+ expect(callback).toHaveBeenCalledWith(null);
});
it('forwards request to provided handler', () => {
const callback = jest.fn() as jest.MockedFunction;
- const handle = jest.fn((cb: DisplayMediaCallback) => cb({ video: false }));
+ const handle = jest.fn((cb: DisplayMediaCallback) => cb(null));
provider.setHandleRequestHandler(handle);
provider.handleDisplayMediaRequest(callback);
- expect(handle).toHaveBeenCalledWith(callback);
- expect(callback).toHaveBeenCalledWith({ video: false });
+ expect(handle).toHaveBeenCalledWith(callback, undefined);
+ expect(callback).toHaveBeenCalledWith(null);
});
it('calls initialize only once and keeps initialized state', async () => {
diff --git a/src/screenSharing/screenPicker/__tests__/PortalPickerProvider.spec.ts b/src/screenSharing/screenPicker/__tests__/PortalPickerProvider.spec.ts
index aa9173870e..b86c742c10 100644
--- a/src/screenSharing/screenPicker/__tests__/PortalPickerProvider.spec.ts
+++ b/src/screenSharing/screenPicker/__tests__/PortalPickerProvider.spec.ts
@@ -25,7 +25,7 @@ describe('PortalPickerProvider', () => {
await provider.initialize();
await provider.handleDisplayMediaRequest(callback);
- expect(callback).toHaveBeenCalledWith({ video: false } as any);
+ expect(callback).toHaveBeenCalledWith(null);
});
it('returns first source when available', async () => {
@@ -48,7 +48,7 @@ describe('PortalPickerProvider', () => {
await provider.handleDisplayMediaRequest(callback);
await Promise.resolve();
- expect(callback).toHaveBeenCalledWith({ video: false } as any);
+ expect(callback).toHaveBeenCalledWith(null);
errorCallback.mockRestore();
});
diff --git a/src/screenSharing/screenPicker/providers/InternalPickerProvider.ts b/src/screenSharing/screenPicker/providers/InternalPickerProvider.ts
index 5116b49f3d..a36d66399a 100644
--- a/src/screenSharing/screenPicker/providers/InternalPickerProvider.ts
+++ b/src/screenSharing/screenPicker/providers/InternalPickerProvider.ts
@@ -1,3 +1,5 @@
+import type { BrowserWindow } from 'electron';
+
import type { DisplayMediaCallback, ScreenPickerProvider } from '../types';
/**
@@ -18,8 +20,9 @@ export class InternalPickerProvider implements ScreenPickerProvider {
private isInitialized = false;
- private handleRequestFn: ((callback: DisplayMediaCallback) => void) | null =
- null;
+ private handleRequestFn:
+ | ((callback: DisplayMediaCallback, originWindow?: BrowserWindow) => void)
+ | null = null;
private initializeFn: (() => Promise) | null = null;
@@ -28,7 +31,10 @@ export class InternalPickerProvider implements ScreenPickerProvider {
* This encapsulates all the internal picker logic with access to ipc.ts state
*/
setHandleRequestHandler(
- handler: (callback: DisplayMediaCallback) => void
+ handler: (
+ callback: DisplayMediaCallback,
+ originWindow?: BrowserWindow
+ ) => void
): void {
this.handleRequestFn = handler;
}
@@ -41,14 +47,17 @@ export class InternalPickerProvider implements ScreenPickerProvider {
this.initializeFn = handler;
}
- handleDisplayMediaRequest(callback: DisplayMediaCallback): void {
+ handleDisplayMediaRequest(
+ callback: DisplayMediaCallback,
+ originWindow?: BrowserWindow
+ ): void {
if (this.handleRequestFn) {
- this.handleRequestFn(callback);
+ this.handleRequestFn(callback, originWindow);
} else {
console.error(
'InternalPickerProvider: handleRequest handler not set. This should be set by ipc.ts'
);
- callback({ video: false });
+ callback(null);
}
}
diff --git a/src/screenSharing/screenPicker/providers/PortalPickerProvider.ts b/src/screenSharing/screenPicker/providers/PortalPickerProvider.ts
index 1cfd36f047..137598825b 100644
--- a/src/screenSharing/screenPicker/providers/PortalPickerProvider.ts
+++ b/src/screenSharing/screenPicker/providers/PortalPickerProvider.ts
@@ -10,6 +10,8 @@ export class PortalPickerProvider implements ScreenPickerProvider {
readonly requiresCacheWarming = false;
handleDisplayMediaRequest(callback: DisplayMediaCallback): void {
+ // Portal is an OS-level dialog, not window-scoped — the originating
+ // window (if any) is irrelevant here.
// On Linux/Wayland, calling getSources() triggers the XDG portal picker.
// The portal typically returns exactly one source on selection or an empty array
// on cancellation; we defensively check for > 0 and use only the first source.
@@ -26,7 +28,7 @@ export class PortalPickerProvider implements ScreenPickerProvider {
} else {
// User cancelled or no source available
console.warn('Screen picker [portal]: No source selected by user');
- callback({ video: false } as any);
+ callback(null);
}
})
.catch((error) => {
@@ -34,7 +36,7 @@ export class PortalPickerProvider implements ScreenPickerProvider {
'Screen picker [portal]: Failed to get source from XDG portal:',
error
);
- callback({ video: false } as any);
+ callback(null);
});
}
diff --git a/src/screenSharing/screenPicker/types.ts b/src/screenSharing/screenPicker/types.ts
index 56673c5833..f7373f8a0c 100644
--- a/src/screenSharing/screenPicker/types.ts
+++ b/src/screenSharing/screenPicker/types.ts
@@ -1,8 +1,16 @@
+import type { BrowserWindow, DesktopCapturerSource } from 'electron';
+
export type ScreenPickerType = 'internal' | 'portal';
-// DisplayMediaCallback matches Electron's setDisplayMediaRequestHandler callback
-// We use 'any' to match the existing code pattern that uses 'as any' casts for flexibility
-export type DisplayMediaCallback = (streams: any) => void;
+// Matches Electron's setDisplayMediaRequestHandler callback signature. To
+// deny, the callback must be invoked with `null` — Electron's own .d.ts types
+// the callback as `(streams: Streams) => void` without `null`, but its
+// runtime (DisplayMediaDeviceChosen) explicitly accepts and expects it for a
+// clean deny. Passing `{}` or `{ video: false }` when video was requested
+// throws a TypeError instead.
+export type DisplayMediaCallback = (
+ streams: { video?: DesktopCapturerSource } | null
+) => void;
// eslint-disable-next-line @typescript-eslint/naming-convention
export interface ScreenPickerProvider {
@@ -18,8 +26,14 @@ export interface ScreenPickerProvider {
/**
* Handle a display media request from the webview.
* Called by setDisplayMediaRequestHandler in main process.
+ * `originWindow`, when provided, is the standalone BrowserWindow that
+ * originated the request (e.g. a webapp popout), so the picker can be
+ * shown there instead of the root window.
*/
- handleDisplayMediaRequest(callback: DisplayMediaCallback): void;
+ handleDisplayMediaRequest(
+ callback: DisplayMediaCallback,
+ originWindow?: BrowserWindow
+ ): void;
/**
* Initialize the picker when video call window is created.
diff --git a/src/screenSharing/screenPickerWindow.ts b/src/screenSharing/screenPickerWindow.ts
new file mode 100644
index 0000000000..bf4e7aa84d
--- /dev/null
+++ b/src/screenSharing/screenPickerWindow.ts
@@ -0,0 +1,69 @@
+import path from 'path';
+
+import type { Event } from 'electron';
+import { app, BrowserWindow } from 'electron';
+
+import { packageJsonInformation } from '../app/main/app';
+
+export type ScreenPickerWindowChannels = {
+ response: string;
+ permission: string;
+ openUrl: string;
+};
+
+export const openScreenPickerWindow = (
+ parent: BrowserWindow,
+ channels: ScreenPickerWindowChannels
+): BrowserWindow => {
+ const parentBounds = parent.getBounds();
+ const width = 780;
+ const height = 680;
+ const x = Math.round(parentBounds.x + (parentBounds.width - width) / 2);
+ const y = Math.round(parentBounds.y + (parentBounds.height - height) / 2);
+
+ const screenPickerWindow = new BrowserWindow({
+ parent,
+ width,
+ height,
+ x,
+ y,
+ show: false,
+ resizable: false,
+ minimizable: false,
+ maximizable: false,
+ fullscreenable: false,
+ autoHideMenuBar: true,
+ webPreferences: {
+ nodeIntegration: true,
+ contextIsolation: false,
+ },
+ });
+
+ screenPickerWindow.loadFile(
+ path.join(app.getAppPath(), 'app/screen-picker-window.html'),
+ {
+ query: {
+ response: channels.response,
+ permission: channels.permission,
+ openUrl: channels.openUrl,
+ },
+ }
+ );
+
+ screenPickerWindow.once('ready-to-show', () => {
+ screenPickerWindow.setTitle(
+ `Screen Sharing - ${packageJsonInformation.productName}`
+ );
+ screenPickerWindow.show();
+ });
+
+ screenPickerWindow.webContents.on('will-navigate', (event: Event) => {
+ event.preventDefault();
+ });
+
+ screenPickerWindow.webContents.setWindowOpenHandler(() => ({
+ action: 'deny',
+ }));
+
+ return screenPickerWindow;
+};
diff --git a/src/screenSharing/serverViewScreenSharing.ts b/src/screenSharing/serverViewScreenSharing.ts
index 236cfb0d30..5d2563bc67 100644
--- a/src/screenSharing/serverViewScreenSharing.ts
+++ b/src/screenSharing/serverViewScreenSharing.ts
@@ -1,4 +1,5 @@
-import type { WebContents } from 'electron';
+import type { WebContents, WebFrameMain } from 'electron';
+import { BrowserWindow, webContents as electronWebContents } from 'electron';
import { handle } from '../ipc/main';
import { isProtocolAllowed } from '../navigation/main';
@@ -6,6 +7,7 @@ import { getRootWindow } from '../ui/main/rootWindow';
import { openExternal } from '../utils/browserLauncher';
import { ScreenSharingRequestTracker } from './ScreenSharingRequestTracker';
import { prewarmDesktopCapturerCache } from './desktopCapturerCache';
+import { requestViaPickerWindow } from './popoutPickerRequest';
import type {
DisplayMediaCallback,
ScreenPickerProvider,
@@ -17,10 +19,48 @@ const serverViewTracker = new ScreenSharingRequestTracker(
'Server view screen sharing'
);
-const createRootWindowPickerHandler =
- (): ((callback: DisplayMediaCallback) => void) => (cb) => {
+const SERVER_VIEW_PICKER_CHANNELS = {
+ response: 'screen-picker/source-responded',
+ permission: 'screen-picker/screen-recording-is-permission-granted',
+ openUrl: 'screen-picker/open-url',
+};
+
+/**
+ * Resolves the standalone BrowserWindow that originated a display-media
+ * request, or null if the request came from a webview guest (the main
+ * server view) rather than a standalone window (e.g. a webapp popout).
+ */
+export const resolveStandaloneOriginWindow = (
+ frame: WebFrameMain | null | undefined
+): BrowserWindow | null => {
+ if (!frame) return null;
+
+ const origin = electronWebContents.fromFrame(frame);
+ if (!origin) return null;
+
+ if (origin.hostWebContents) return null;
+
+ return BrowserWindow.fromWebContents(origin);
+};
+
+const createServerViewPickerHandler =
+ (): ((
+ callback: DisplayMediaCallback,
+ originWindow?: BrowserWindow
+ ) => void) =>
+ (cb, originWindow) => {
prewarmDesktopCapturerCache();
+ if (originWindow && !originWindow.isDestroyed()) {
+ requestViaPickerWindow(
+ serverViewTracker,
+ originWindow,
+ SERVER_VIEW_PICKER_CHANNELS,
+ cb
+ );
+ return;
+ }
+
serverViewTracker.createRequest(cb, () => {
getRootWindow().then((rootWindow) => {
if (rootWindow && !rootWindow.isDestroyed()) {
@@ -49,7 +89,7 @@ const initializeProvider = (): Promise => {
provider = new PortalPickerProvider();
} else {
const internalProvider = new InternalPickerProvider();
- internalProvider.setHandleRequestHandler(createRootWindowPickerHandler());
+ internalProvider.setHandleRequestHandler(createServerViewPickerHandler());
provider = internalProvider;
}
@@ -74,15 +114,23 @@ export const setupServerViewDisplayMedia = (
const currentProvider = provider;
try {
guestWebContents.session.setDisplayMediaRequestHandler(
- (_request, cb) => {
+ (request, cb) => {
try {
- currentProvider.handleDisplayMediaRequest(cb);
+ const originWindow = resolveStandaloneOriginWindow(request.frame);
+ // Electron's own callback type omits `null`, but its runtime
+ // accepts (and requires) it for a clean deny — see DisplayMediaCallback.
+ currentProvider.handleDisplayMediaRequest(
+ cb as DisplayMediaCallback,
+ originWindow ?? undefined
+ );
} catch (error) {
console.error(
'Server view screen sharing: error in handler:',
error
);
- cb({ video: false } as any);
+ // See boundary-cast note above: Electron's runtime accepts null
+ // for a clean deny even though its callback type omits it.
+ (cb as DisplayMediaCallback)(null);
}
},
{ useSystemPicker: false }
@@ -117,23 +165,25 @@ export const setupServerViewDisplayMedia = (
};
/**
- * Routes a display-media request to the server-view screen picker (root window).
+ * Routes a display-media request to the server-view screen picker (root window
+ * by default, or `originWindow` when the request originated from a popout).
* 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
+ callback: DisplayMediaCallback,
+ originWindow?: BrowserWindow
): void => {
const dispatch = (): void => {
if (!provider) {
- callback({ video: false } as any);
+ callback(null);
return;
}
try {
- provider.handleDisplayMediaRequest(callback);
+ provider.handleDisplayMediaRequest(callback, originWindow);
} catch (error) {
console.error('Server view screen sharing: error in handler:', error);
- callback({ video: false } as any);
+ callback(null);
}
};
@@ -149,7 +199,7 @@ export const handleServerViewDisplayMediaRequest = (
'Server view screen sharing: error initializing provider:',
error
);
- callback({ video: false } as any);
+ callback(null);
});
};
diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts
index fa50d0bdfb..85ce3883d0 100644
--- a/src/videoCallWindow/ipc.ts
+++ b/src/videoCallWindow/ipc.ts
@@ -18,6 +18,7 @@ import {
getDesktopCapturerCacheStatus,
prewarmDesktopCapturerCache,
} from '../screenSharing/desktopCapturerCache';
+import { requestViaPickerWindow } from '../screenSharing/popoutPickerRequest';
import type {
DisplayMediaCallback,
ScreenPickerProvider,
@@ -25,6 +26,7 @@ import type {
import { checkScreenRecordingPermission } from '../screenSharing/screenRecordingPermission';
import {
handleServerViewDisplayMediaRequest,
+ resolveStandaloneOriginWindow,
setupServerViewDisplayMedia,
} from '../screenSharing/serverViewScreenSharing';
import { select, dispatchLocal } from '../store';
@@ -80,6 +82,12 @@ const videoCallScreenSharingTracker = new ScreenSharingRequestTracker(
'Video call screen sharing'
);
+const VIDEO_CALL_PICKER_CHANNELS = {
+ response: 'video-call-window/screen-sharing-source-responded',
+ permission: 'video-call-window/screen-recording-is-permission-granted',
+ openUrl: 'video-call-window/open-url',
+};
+
// Helper function to log URL changes
const setPendingVideoCallUrl = (url: string, reason: string) => {
const previous = pendingVideoCallUrl;
@@ -204,8 +212,10 @@ const cleanupVideoCallWindow = () => {
// handler took over (no-op on isolated/fallback sessions).
void restoreServerViewHandler(capturedCall);
- // Clean up screen sharing listener before removing window listeners
- videoCallScreenSharingTracker.cleanup();
+ // Tear down screen sharing (active + queued) before removing window
+ // listeners — silent cleanup() would orphan a popout-parented picker
+ // window whose request is still pending.
+ videoCallScreenSharingTracker.cancelAll();
videoCallWindow.removeAllListeners();
@@ -252,12 +262,30 @@ const cleanupVideoCallWindow = () => {
// Internal picker handler function - uses shared tracker
const createInternalPickerHandler =
- (): ((callback: DisplayMediaCallback) => void) => (cb) => {
+ (): ((
+ callback: DisplayMediaCallback,
+ originWindow?: BrowserWindow
+ ) => void) =>
+ (cb, originWindow) => {
+ if (
+ originWindow &&
+ originWindow !== videoCallWindow &&
+ !originWindow.isDestroyed()
+ ) {
+ requestViaPickerWindow(
+ videoCallScreenSharingTracker,
+ originWindow,
+ VIDEO_CALL_PICKER_CHANNELS,
+ cb
+ );
+ return;
+ }
+
if (!videoCallWindow || videoCallWindow.isDestroyed()) {
console.warn(
'Screen sharing request rejected - video call window not available'
);
- cb({ video: false } as any);
+ cb(null);
return;
}
@@ -316,9 +344,20 @@ const setupWebviewHandlers = (webContents: WebContents) => {
webviewWebContents.session.setDisplayMediaRequestHandler(
(request, cb) => {
try {
+ const originWindow = resolveStandaloneOriginWindow(request.frame);
+ // The root window can't originate a request on this session, and
+ // the call window itself is handled by the internal picker path
+ // below — only a genuine popout (e.g. a webapp window.open) needs
+ // its own picker window.
+ const popoutOrigin =
+ originWindow && originWindow !== videoCallWindow
+ ? originWindow
+ : null;
+
// 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.
+ // webview, or a popout sharing this session) falls back to the
+ // server-view picker.
if (call?.isSharedSession) {
const originWebContents = request.frame
? electronWebContents.fromFrame(request.frame)
@@ -328,14 +367,24 @@ const setupWebviewHandlers = (webContents: WebContents) => {
originWebContents.hostWebContents?.id ===
videoCallWindow?.webContents.id;
if (!fromCallWindow) {
- handleServerViewDisplayMediaRequest(cb);
+ // Electron's own callback type omits `null`, but its runtime
+ // accepts (and requires) it for a clean deny — see DisplayMediaCallback.
+ handleServerViewDisplayMediaRequest(
+ cb as DisplayMediaCallback,
+ popoutOrigin ?? undefined
+ );
return;
}
}
- currentProvider.handleDisplayMediaRequest(cb);
+ currentProvider.handleDisplayMediaRequest(
+ cb as DisplayMediaCallback,
+ popoutOrigin ?? undefined
+ );
} catch (error) {
console.error('Error in screen picker handler:', error);
- cb({ video: false } as any);
+ // See boundary-cast note above: Electron's runtime accepts null
+ // for a clean deny even though its callback type omits it.
+ (cb as DisplayMediaCallback)(null);
}
},
{ useSystemPicker: false } // Always false - portal handled via callback on Linux
@@ -741,8 +790,8 @@ const openVideoCallWindow = async (
videoCallWindow.on('closed', () => {
console.log('Video call window closed - destroying completely');
- // Clean up screen sharing listener
- videoCallScreenSharingTracker.cleanup();
+ // Tear down screen sharing (active + queued) — see cancelAll() note above.
+ videoCallScreenSharingTracker.cancelAll();
// This call's unified handler took over the shared session's
// display-media handler. Restore the plain server-view handler so
@@ -781,8 +830,8 @@ const openVideoCallWindow = async (
'Video call window close initiated - preventing JS execution'
);
- // Clean up screen sharing listener
- videoCallScreenSharingTracker.cleanup();
+ // Tear down screen sharing (active + queued) — see cancelAll() note above.
+ videoCallScreenSharingTracker.cancelAll();
try {
if (videoCallWindow && !videoCallWindow.isDestroyed()) {
@@ -1146,9 +1195,12 @@ export const startVideoCallWindowHandler = (): void => {
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();
+ // Settle any foreign in-flight or queued request (e.g. a popout-originated
+ // one sharing this tracker) and close its picker window before this
+ // handler's own raw ipcMain.once below claims the response channel —
+ // otherwise that request's callback never fires and its picker window is
+ // never closed.
+ videoCallScreenSharingTracker.cancelAll();
videoCallWindow.webContents.send('video-call-window/open-screen-picker');
diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts
index b1baca8261..061c7b3fbf 100644
--- a/src/videoCallWindow/main/ipc.main.spec.ts
+++ b/src/videoCallWindow/main/ipc.main.spec.ts
@@ -159,11 +159,13 @@ jest.mock('../../screenSharing/screenPicker', () => ({
})),
InternalPickerProvider: class {},
}));
-// ScreenSharingRequestTracker only needs to be constructible + .cleanup().
+// ScreenSharingRequestTracker only needs to be constructible + .cleanup()/.cancelAll().
jest.mock('../../screenSharing/ScreenSharingRequestTracker', () => ({
ScreenSharingRequestTracker: class {
cleanup = jest.fn();
+ cancelAll = jest.fn();
+
createRequest = jest.fn();
},
}));