Taking desktop screenshots in Electron v35

An Electron Fiddle window shows a test application that took a screenshot of the screen, including itself.

This is a small post about how to use electron.desktopCapturer to capture a screenshot in the new realities of main/renderer process separation.

What's the trick

desktopCapturer is intended for building custom capture source picker interfaces, which includes support for fetching thumbnails of screens and/or windows.

And those thumbnails are just screenshots that are scaled to desired size if necessary!

How it is

These are snippets for Electron Fiddle as that's by far the easiest way to do small tests on Electron.

Main process JS

3/4 of this code is Electron boilerplate so I'll highlight the important part:

const { app, BrowserWindow, ipcMain, desktopCapturer } = require('electron')
const path = require('node:path')

function createWindow () {
  const mainWindow = new BrowserWindow({
    width: 1280, height: 800,
    webPreferences: { preload: path.join(__dirname, 'preload.js') }
  })
  mainWindow.loadFile('index.html')
  mainWindow.webContents.openDevTools()
}

app.whenReady().then(() => {
  // ↓ ↓ ↓
  ipcMain.handle('captureScreenshot', async (e, width, height) => {
    const sources = await desktopCapturer.getSources({
      types: ['screen'],
      thumbnailSize: { width, height }
    })
    return sources[0].thumbnail.toDataURL()
  })
  // ↑ ↑ ↑
  createWindow()
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

Now that use of desktopCapturer in the renderer process has been outlawed, you have to put it in the main process instead and expose it through IPC.

See "Notes" on picking through displays.

Preload JS

Here you might expose your IPC function to the renderer without exposing IPC to the renderer.

const { contextBridge, ipcRenderer } = require('electron/renderer')

contextBridge.exposeInMainWorld('nativeBits', {
  captureScreenshot: (width, height) => ipcRenderer.invoke('captureScreenshot', width, height)
})

If you do expose IPC to the renderer process, you could skip this and invoke IPC in there directly.

Renderer JS

And with all that done, you can finally call the exposed function and use the returned Data URL for your image element.

document.getElementById('take-screenshot').addEventListener('click', async () => {
  const dataURL = await nativeBits.captureScreenshot(640, 360)
  document.getElementById('screenshot').src = dataURL
})

HTML

Most of this is also boilerplate, however: if you are defining a Content Security Policy, you will need to add data: to the img-src policy.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; img-src 'self' data:">
    <title>Screenshots, huh?</title>
  </head>
  <body>
    <input id="take-screenshot" type="button" value="Take screenshot" />
    <br/><img id="screenshot" />
    <script src="./renderer.js"></script>
  </body>
</html>

How it was

Prior to 2022 / Electron 17[?] you could just

const { desktopCapturer } = require('electron')

document.getElementById('take-screenshot').addEventListener('click', async () => {
  const sources = await desktopCapturer.getSources({
      types: ['screen'],
      thumbnailSize: { width: 640, height: 360 }
    })
  document.getElementById('screenshot').src = sources[0].thumbnail.toDataURL()
})

This is about half the line count of the IPC version, but the IPC version is undeniably more secure as we are no longer exposing other native APIs to the renderer.

Unfortunately, if your application is offline-only, all of this is little help against the last remaining threat: the user themselves.

Notes and caveats

You can combine this with the screen API to pick the display that you'd like to capture and/or extract size information out of it.

For example, the following would capture the user's smallest display at full resolution:

ipcMain.handle('captureScreenshot', async (e) => {
  const displays = screen.getAllDisplays()
  const getArea = (display) => display.size.width * display.size.height
  displays.sort((a, b) => getArea(a) - getArea(b))
  const display = displays[0]
  const sources = await desktopCapturer.getSources({
    types: ['screen'],
    thumbnailSize: display.size
  })
  const source = sources.filter(s => s.display_id == "" + display.id)[0]
  return source.thumbnail.toDataURL()
})

Here you may also note a certain caveat with this whole approach: even though we only need a screenshot of one display, we can only fetch thumbnails for all of them. The more displays the user has, the slower the function gets!

In other words, if you intend to do this often, you should consider using the actual Screen Capture API.

Related posts:

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.