Creating an Electron Window-to-Window Message Passing System

We create open-source because we love it, and we share our finding so everyone else can benefit as well.

Creating an Electron Window-to-Window Message Passing System

In Electron, it is certainly easy to create a multi-window application app in Electron, but what if you want to communicate between these windows? Well, here is where we run into some short-comings from the restrictions that comes from avoiding remote code execution in the Chrome V8 Engine, causing a lack of options for communication between windows. Since there is no way to directly communicate between windows, we have to create our own message passing system to accomplish our goal.

Since we cannot communicate between windows, we need a way to communicate back and forth between our windows, which we can accomplish through the main process.

Starting with the overall structure, we set the main process to track each initialized window, and set an identifier. So with each window’s creation, we push the initialized BrowserWindow object into shared array object, and then remove it on window close.

Creating and Tracking Windows

To simplify this, we can use a single function that will create a window based on three params: a BrowerWindow params Object, a Window LoadUrl Path, and a RegEx Expression for the Title.

Here’s an example of that function, and using it to create the About Window. For those who want limit individual windows to a single instance, you can see it will also focus the window when it already exists:

windows.js (imported into main.js and menu.js)

const windowList = [] // shared array

function createWindow(windowOptions, windowPath, winRegex) {
  if(!windowList.filter(winItem => winItem.webContents.browserWindowOptions.title && winRegex.test(winItem.webContents.browserWindowOptions.title)).length) 
{
    let win = new BrowserWindow(Object.assign(windowOptions))
    win.loadURL(windowPath)
    windowList.push(win)

    win.on('close', () => {
      // remove window when it is closed
      windowList.splice(windowList.indexOf(win), 1)
    })
  } else {
    // if window already open, focus
    windowList.filter(winItem => winItem.webContents.browserWindowOptions.title && winRegex.test(winItem.webContents.browserWindowOptions.title))[0].focus()
  }
}


const aboutPath = process.env.NODE_ENV === 'development' ? path.join('file://', process.cwd(), 'src', 'about.html') : path.join('file://', __dirname, 'about.html')

const aboutRegex = /^About MyApp$/

// example use
createWindow({
  title: 'About MyApp',
  width: 300,
  height: process.platform !== 'darwin' ? 330 : 300,
  maxHeight: 330,
  maxWidth: 300,
  minWidth: 300,
  minHeight: 300,
  frame: process.platform !== 'darwin' ? true : false,
  webPreferences: {
    devTools: process.env.NODE_ENV === 'development',
    nodeIntegration: true
  }
}, aboutPath, aboutRegex);

module.exports = {
  createWindow: createWindow
  windowList: windowList
}

Not only do we have a nice simple way to create and track our windows, but we also have a way to cleanup when we’re done. All that’s needed is to create these Windows somewhere in our electron app.

Since you can only get the current window’s ID, a big issue has been a good way to track down the ID of the destination window. So to do this, we will initiate lookups using the window title, giving a solid way to identify windows, especially when the ID can change with each initialization.

The Message Passing System

The overall concept is to create a centralized system in our Main Process which will act as a router between the windows. We do this by setting up two main process listeners, one for receiving requests while looking up the window and passing data, and another for handling responses and payloads.

main.js

ipcMain.on('message-main-request', (event, fromWindowId, toWindowTitle, withWindowEvent, ...args) => {
  let toWindowId = windowList.filter(w => w.webContents.browserWindowOptions.title === toWindowTitle)[0].id
  let browserWindow = BrowserWindow.fromId(toWindowId)
  browserWindow.webContents.send(withWindowEvent, fromWindowId, ...args)
});

ipcMain.on('message-request-reply', (event, fromWindowId, withWindowEvent, ...args) => {
  let browserWindow = BrowserWindow.fromId(fromWindowId)
  browserWindow.webContents.send(withWindowEvent, fromWindowId, ...args)
})

With the core now setup, we can now setup a secondary window, for example an About renderer process, to request and receive state from the main renderer process.

AboutWindow.js

constructor(props) {
  super(props)

  this.current = remote.getCurrentWindow()
}

componentDidMount() {
  this.interval = setInterval(() => {
    ipcRenderer.send('message-main-request', this.current.id, 'MyApp', 'message-request')
  }, 2000)

  ipcRenderer.on('message-main-response', (event, windowId, reduxData) => this.setState({reduxState: reduxData}))
}

In the above, the component is set to send requests once the component mounts, sending a request to the message-main-request event in the Main Process, and passes the following params:

  • the about window ID (remote.getCurrentWindow().id)
  • the destination window’s Title (MyApp)
  • the destination window’s receiving event (message-request)

Our main process message-main-request IPC listener first takes the destination window’s title, and does a lookup for the window with the matching title, and grabs the ID. With the ID, it sends a new IPC Renderer request with the withWindowEvent as the event string “message-request”, the the originating window’s ID, and it also passes any other args from the first request.

App.js

componentDidMount() {
  ipcRenderer.on('message-request', (event, fromWindowId, _type) => event.sender.send('message-request-reply', fromWindowId, 'message-main-response', this.stateUpdate()))
}

stateUpdate = () => this.props.state

On the receiving end in the Main Renderer Process, we have an IPC listener for ‘message-request’, which receives the request from the Main Process, and in this example, return the following:

  • event string
  • the origin ID (About Window ID)
  • origin window receiving event string
  • payload data (in this case the local Redux state)

When it returns the event response to the ‘message-request-reply’ listener, the Main Process Listener will make a new call back to the About window’s ‘message-main-response’ IPC Listener, where the payload is passed back through the spread operator args, how cool!

As you can see, the system itself is fairly simple, and at the same time quite versatile. Because of the spread args you can pass whatever you want between any of your windows, without having to touch the main process listeners. Because of this, you can create whatever renderer process listeners you want, as long as they follow the main process listener templates:

Initiating Window Request Template

ipcRenderer.send('message-main-request', <RequestingWindowId>, <DestinationWindowTitle>, <DestinationListenerEvent>, <PayloadArgs>)

Receiving Window Listener Template

  ipcRenderer.on(<DestinationListenerEvent>, (event, <RequestingWindowId>, _type) => event.sender.send('message-request-reply', <RequestingWindowId>, <RequestingWindowListenerEvent>, <Payload>))

No matter what your situation, this will allow you to pass data between your windows, without having to duplicate state environments, or increasing overhead on your renderer processes.