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(); }, }));