This post is the fourth in my series on Electron and React. In this post I will cover basic examples of Electron’s Inter-Process Communication or “IPC”.
I would suggest you read the previous parts of the series before reading this post. The first part of the series covers Electron fundamentals and the second part explains how React applications can be rendered in browsers. The third part adds the git repository the examples build on.
I also assume that you understand React and JSX on a basic level. If you do not, I recommend you to read at least the basics from React documentation.
Posts in this series:
- Electron and React, Part 1: How does Electron work?
- Electron and React, Part 2: React JSX and the browser
- Electron and React, Part 3: A project template
- Electron and React, Part 4: IPC (you are here)
For the impatient
The project repository is available in Github: https://github.com/jlundan/electron-react-example. If you want to jump right to it, feel free to do so. However, I would recommend you to read through this post, where I will walk through the example and explain a few things which might not be self explanatory.
An evolving repository
Electron-react -example changes over time, which means the repository’s source code in the main branch might not look exactly like the snippets on the page. If you wish to restore the repository to the state it was when this post was written, you can use the v2.0 version. If you cloned the repository, you can checkout v2.0 tag with git.
About security and context isolation
Electron contains powerful functionality, including the NodeJS filesystem API. Allowing direct access to these API from the renderer would be dangerous.
The JavaScript running in the BrowserWindow would have full access to the filesystem where an Electron application is started. This means, depending on the Operating System access rights, a malicious code injection might have permissions to wipe out the filesystem, or read any file on it (including sensitive information such as keys and serialised passwords) at will. The malicious code could then further process the passwords and keys and send them to a network server.
This obviously would be bad.
To mitigate this risk, Electron by default restricts renderer’s access to full Electron APIs, including the IPC functionality. It is possible to turn this restriction off, but I would highly advise against it. Although your BrowserWindow loads resources from disk, you are still using node modules which can be compromised. Node modules themselves depend on other node modules, and it only takes one of these to be compromised without you knowing about it, and the attacker has possibly full access to your users filesystems.
So, please do not turn the restriction off unless it is absolutely essential – and even in this case I would advise you to think about alternative solutions if possible.
The contentBridge and preload scripts
The contentBridge is a feature of Electron, which allows you to expose NodeJS functionality to the renderer process in a safe way. The technology is built on top of Chromium’s Content Scripts used by the Chromium Browser (and Google Chrome) extensions. Electron calls these “preload scripts”, which can be injected into a BrowserWindow.
Consider code from the electron-react-example (src/main/preload.js):
const { contextBridge, ipcRenderer} = require('electron') const ipcHandler = { send(channel, ...args) { ipcRenderer.send(channel, ...args); }, invoke(channel, ...args) { return ipcRenderer.invoke(channel, ...args); }, once(channel, callback) { ipcRenderer.once(channel, (_event, ...args) => callback(...args)); }, on(channel, callback) { const subscription = (_event, ...args) => callback(...args) ipcRenderer.on(channel, subscription); return () => { ipcRenderer.removeListener(channel, subscription); }; }, clear(channel) { ipcRenderer.removeAllListeners(channel); } }; contextBridge.exposeInMainWorld('ipc', ipcHandler);
The code defines an object called “ipcHandler” which is then given to Electron’s contentBridge implementation to be “exposed in the main world” (“main world” means the renderer). The object declares functions which can be executed via the contentBridge using “exposeInMainWorld” and an alias (in this case “ipc”). The object is then available in the global “window” object via the alias, which makes the “ipcHandler” functions available to the renderer.
window.ipc.send('set-title', windowTitle);
Before the renderer can see the “ipcHandler” via its given alias, the code must be injected into the BrowserWindow via the webPreferences preload property in the main process (src/main/main.js).
... const win = new BrowserWindow({ width: 800, height: 600, icon: path.join(__dirname, '..', '..', 'assets', 'icons', '128x128.png'), webPreferences: { preload: path.join(__dirname, 'preload.js') } }); ...
IpcRenderer, ipcMain and channels
You might have noticed that the “ipcHandler” used Electron’s ipcRenderer. If you look at the main process in electron-react-example, you can see that the code uses Electron’s “ipcMain”
... ipcMain.on('set-title', (_event, title) => { win.setTitle(title); }); ...
IpcMain and ipcRenderer are counterparts for the communication between the main process and the renderer. Communication happens on a channel whose name both parties know.
- ipcMain contains messaging functionality for the main process
- ipcRenderer contains messaging functionality for a renderer
- ipcMain and ipcRenderer can exchange messages between them
- both allow setting callback functions when listening for messages, either from main process code, or from renderer code
- messages sent to a certain channel can be listened to on the other side using the same channel name. For example I used channel “set-title” above: renderer sends to that channel and the main process listens on the same channel
IpcMain is called directly in the main process, while ipcRenderer is called indirectly via preload scripts and the contentBridge.
Once more, please?
The contentBridge and preload scripts might sound a bit confusing, but at the heart of it is that ipcRenderer should not be called directly from the renderer. Doing so, would expose the entire API to possible JavaScript injection attacks in the renderer. Therefore, it is recommended that the preload script controls and limits what Electron functionality (including the ipcRenderer) the renderer can call.
IpcMain needs ipcRenderer to exchange messages, so the renderer must be able to access the ipcRenderer in some way. By wrapping the ipcRenderer calls into a function in the “ipcHandler”, allows the ipcRenderer to be accessed in a controlled way. The wrapping function decides which ipcRenderer’s methods to call, and can for example sanitise the arguments which will go to the ipcRenderer’s method calls.
For example: in my code, I allow the renderer to access only five functions, the renderer does not know what happens inside them. The functions themselves use powerful Electron functionality, but only in a way I allow. This way the renderer has indirect access to ipcRenderer and can exchange messages between it and ipcMain, while keeping the ipcRenderer API hidden from possible malicious code.
Messages
There are three main scenarios for messaging between the main process and a renderer:
- One-way from main process to a renderer
- One-way from a renderer to the main process
- Two-way from a renderer to the main process and back as a response
From the main process to a renderer
Sending a message from the main process to a renderer does not happen via the ipcMain, instead it involves using a BrowserWindow’s webContents property and its send method. The Main Process can send messages to any open BrowserWindow using the mechanism. The renderer can listen for the events using either ipcRenderer.on() or ipcRenderer.once(). Using ipcRenderer.once() stops listening for events after the first one has been received, where ipcRenderer.on() will listen to all events sent to the channel.
// src/renderer/App.jsx <button onClick={() => window.ipc.send('request-greeting', greetingText)}>Request greeting</button> // src/main/main.js ipcMain.on('request-greeting', (_event, greeting) => { win.webContents.send('greeting', `Hello: ${greeting}`) }); // src/renderer/App.jsx React.useEffect(() => { window.ipc.once( 'greeting', (data) => { setGreetingFromMain(data); } ); }, []); // src/main/preload.js const ipcHandler = { ... once(channel, callback) { ipcRenderer.once(channel, (_event, ...args) => callback(...args)); }, ... contextBridge.exposeInMainWorld('ipc', ipcHandler);
In the example the renderer first requests a greeting from main using the “request-greeting” channel and specifies the greeting text. The main process listens to the message on the same channel. When the main process receives a message on the channel, it uses webContents.send() to send a message to the renderer on another channel. The renderer listens for messages using ipcRenderer.once() and renders the response on the screen when it arrives. In this example, the renderer uses once() method, which means that the renderer would not receive any additional messages on this channel, unless the once() listener would be registered again.
From an renderer to the main process
Sending a message from the renderer to the main process happens with the ipcRenderer.send() method. The main process listens with ipcMain.on().
// src/renderer/App.jsx <button onClick={() => window.ipc.send('set-title', windowTitle)}>Set window title</button> // src/main/main.js ipcMain.on('set-title', (_event, title) => { win.setTitle(title); }); // src/main/preload.js const ipcHandler = { send(channel, ...args) { ipcRenderer.send(channel, ...args); }, ... contextBridge.exposeInMainWorld('ipc', ipcHandler);
I have prefixed the event argument used in ipcMain.on() to indicate I am not using it (I am only interested in the title argument). I consider this a good practice, since it immediately tells the reader that the argument is not used anywhere.
From a renderer to the main process (two-way)
Last, but definitely not the least, is the mechanism I would recommend you to use most. This mechanism allows the renderer to send a message to the main process using promises.
// src/renderer/App.jsx ... async function loadDependencies() { setDependencies(await window.ipc.invoke('load-dependencies')); } ... <button onClick={loadDependencies}>Load dependencies</button> // src/main/main.js ... ipcMain.handle('load-dependencies', async (_event) => { const dependencies = require('../../package.json').devDependencies; return Object.entries(dependencies).map(([key, value]) => { return { name: key, version: value }; }); }); // src/main/preload.js const ipcHandler = { ... invoke(channel, ...args) { return ipcRenderer.invoke(channel, ...args); }, ... contextBridge.exposeInMainWorld('ipc', ipcHandler);
The message is sent using ipcRenderer.invoke() on the renderer side and received on the main process side with ipcMain.handle(), which can return a value. The value is automatically wrapped in the response, therefore the renderer must wait for the promise to be fulfilled either with await or .then().
Closing thoughts
The contentBridge and preload scripts might be a bit difficult to grasp on the first try, but I hope I managed to explain the topic well enough. Please leave a comment if you think I missed something!
As soon as you figure the contentBridge out, messaging between the renderer and the main process becomes quite simple. You will probably end up using the invoke-handle pattern most of the time, except scenarios where the message originates on the main process side.
I have not covered renderer-to-renderer messaging in this post, since it is quite a rare scenario, but if you would like me to write about it, please leave a comment!
Thank you very much! This is great! This is what I needed.