Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

const { app, ipcMain, desktopCapturer, systemPreferences, shell, session } = require('electron')
const { app, BrowserWindow, ipcMain, desktopCapturer, systemPreferences, shell, session } = require('electron')
const { default: mri } = require('mri')
const { spawn } = require('node:child_process')
const path = require('node:path')
Expand Down Expand Up @@ -86,6 +86,12 @@ ipcMain.on('app:grantUserGesturedPermission', (event, id) => {
ipcMain.on('app:toggleDevTools', (event) => event.sender.toggleDevTools())
ipcMain.handle('app:anything', () => { /* Put any code here to run it from UI */ })
ipcMain.on('app:openChromeWebRtcInternals', () => openChromeWebRtcInternals())
ipcMain.on('app:setScreenCaptureProtection', (event, active) => {
// Exclude the Talk window from screen capture while it shares a whole screen, so the
// window can't appear inside its own shared stream (the "hall of mirrors").
// setContentProtection is a no-op on Linux.
BrowserWindow.fromWebContents(event.sender)?.setContentProtection(!!active)
})
ipcMain.handle('app:update:check', async () => await checkForUpdate({ forceRequest: true }))
ipcMain.handle('app:getDesktopCapturerSources', async () => {
// macOS 10.15 Catalina or higher requires consent for screen access
Expand Down
8 changes: 8 additions & 0 deletions src/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ const TALK_DESKTOP = {
* @return {Promise<{ id: string, name: string, icon?: string }[]|null>}
*/
getDesktopCapturerSources: () => ipcRenderer.invoke('app:getDesktopCapturerSources'),
/**
* Exclude (or restore) this window from OS screen capture while sharing a whole screen,
* to prevent the Talk window from recursing into an infinite "hall of mirrors".
* No effect on Linux (setContentProtection is not supported there).
*
* @param {boolean} active - Whether to protect the window from capture
*/
setScreenCaptureProtection: (active) => ipcRenderer.send('app:setScreenCaptureProtection', active),
/**
* Relaunch an entire application
*/
Expand Down
88 changes: 88 additions & 0 deletions src/talk/renderer/screensharing/screenCaptureProtection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/**
* Legacy Chromium desktop-capture constraints used by Electron's getUserMedia path.
*/
type LegacyDesktopConstraints = {
mandatory?: {
chromeMediaSource?: string
chromeMediaSourceId?: string
}
}

/**
* Whether the given constraints request a whole-screen desktop capture (the entire
* desktop or a specific monitor) rather than a single window. Only screen captures can
* include the Talk window itself and produce the infinite "hall of mirrors".
*
* @param constraints - getUserMedia constraints
*/
function isScreenCapture(constraints: MediaStreamConstraints): boolean {
const video = constraints.video
if (!video || typeof video === 'boolean') {
return false
}
const mandatory = (video as unknown as LegacyDesktopConstraints).mandatory
if (!mandatory || mandatory.chromeMediaSource !== 'desktop') {
return false
}
const sourceId = mandatory.chromeMediaSourceId
// No sourceId → entire desktop; "screen:"/"entire-desktop:" → a monitor; "window:" → a single window
return !sourceId || sourceId.startsWith('screen:') || sourceId.startsWith('entire-desktop:')
}

/**
* While the user shares a whole screen, exclude the Talk window from screen capture
* (`BrowserWindow.setContentProtection`) so it does not appear inside the shared stream —
* which would otherwise recurse into an infinite "hall of mirrors". No-op on Linux, where
* the platform has no capture-exclusion primitive (there the spreed-side overlay applies).
*
* Implemented by wrapping getUserMedia so both the start (a screen capture is requested)
* and the end (the capture track stops) are observed, without any change to Talk (spreed).
*/
export function setupScreenCaptureProtection(): void {
const mediaDevices = navigator.mediaDevices
if (!mediaDevices?.getUserMedia) {
return
}

// Reset to a known baseline: a freshly (re)loaded page has no active screen share, so the
// window must be capturable until one starts. This also clears protection that may have been
// left enabled if the page was reloaded while sharing.
window.TALK_DESKTOP.setScreenCaptureProtection(false)

const originalGetUserMedia = mediaDevices.getUserMedia.bind(mediaDevices)

mediaDevices.getUserMedia = async function(constraints?: MediaStreamConstraints): Promise<MediaStream> {
const stream = await originalGetUserMedia(constraints)

if (constraints && isScreenCapture(constraints)) {
window.TALK_DESKTOP.setScreenCaptureProtection(true)

let released = false
const releaseProtection = () => {
if (released) {
return
}
released = true
window.TALK_DESKTOP.setScreenCaptureProtection(false)
}

for (const track of stream.getVideoTracks()) {
// 'ended' covers the user stopping via the OS/browser picker;
// wrapping stop() covers Talk stopping the share programmatically.
track.addEventListener('ended', releaseProtection, { once: true })
const originalStop = track.stop.bind(track)
track.stop = () => {
originalStop()
releaseProtection()
}
}
}

return stream
}
}
4 changes: 4 additions & 0 deletions src/talk/renderer/talk.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { setupWebPage } from '../../shared/setupWebPage.js'
import { setupScreenCaptureProtection } from './screensharing/screenCaptureProtection.ts'
import { createTalkDesktopApp } from './TalkDesktop.app.ts'

import '../../shared/assets/styles.css'
Expand All @@ -12,6 +13,9 @@ import './talk.styles.css'

await setupWebPage()

// Exclude the Talk window from screen capture while sharing a whole screen (anti "hall of mirrors")
setupScreenCaptureProtection()

await createTalkDesktopApp()

// HOTFIX: prevent invalid links <a href="#"> used in NcListItem as a button from breaking routing in Vue Router 4 with Hash History
Expand Down