electron JS

December 24, 2023

After much thought I think I'll build Project Cobrol as a software project, served directly to the OS. It'll add a little friction in adoption side, but give me room to expand on the application side of things, the kind of functionalities I'd like. For it, I started studying electronJS. here's some notes.



Main process

  • Each Electron app has a single main process, which acts as the application's entry point. The main process runs in a Node.js environment, meaning it has the ability to require modules and use all of Node.js APIs.
  • The main process' primary purpose is to create and manage application windows with the BrowserWindow module.

Preload Script

  • Preload scripts contain code that executes in a renderer process before its web content begins loading. These scripts run within the renderer context, but are granted more privileges by having access to Node.js APIs.

  • Although preload scripts share a window global with the renderer they're attached to, you cannot directly attach any variables from the preload script to window because of the contextIsolation default.

  • Context Isolation means that preload scripts are isolated from the renderer's main world to avoid leaking any privileged APIs into your web content's code.

    • the point of context isolation is that:
      • without context isolation you can have access to every node API using window.
      • but that is extremely risky, as a hacker would have access to everything that way
      • hence, we use context isolation and the webcontents communicate with the ain module via a bridge.
      • this way only a specific set of APIs get exposed to the web contents side of the world

ipcRenderer

  • The main process can create multiple renderer processes using the BrowserWindow module. Each BrowserWindow is a separate and unique renderer process that includes a DOM, access to the Chromium web APIs, and the Node built-in module.

image.png on the left are what ipcRenderer can call to send messages to ipcMain. on the right are events that are fired when ipcMain sends a msg to ipcRenderer

image.png

  • one way messaging system

  • to receive on the main side -> ipcMain.on('custom-event', () => {})

  • to get a msg back from the main process use ipcRenderer.invoke

    • Returns Promise - Resolves with the response from the main process.
    • // Renderer process
      ipcRenderer.invoke("some-name", someArgument).then(result => {
        // ...
      })
      
      // Main process
      ipcMain.handle("some-name", async (event, someArgument) => {
        const result = await doSomeWork(someArgument)
        return result
      })

Node APIs

  • image.png
  • A given process in JavaScript executes our code on a single thread and can do only one thing at a time. By delegating these tasks to the main process, we can be confident that only one process is performing reading or writing to a given file or database at a time. Other tasks follow the normal JavaScript protocol of patiently waiting in the event queue until the main process is done with its current task.
  • image.png

IPC Communications

  • Pattern 1: Renderer to main (one-way)

    • use the ipcRenderer.send API to send a message that is then received by the ipcMain.on API
  • Pattern 2: Renderer to main (two-way)

    • A common application for two-way IPC is calling a main process module from your renderer process code and waiting for a result. This can be done by using ipcRenderer.invoke paired with ipcMain.handle

    • ////////////////////////////////////////////// index.html
      <body>
          <button type="button" id="btn">Open a File</button>
          File path: <strong id="filePath"></strong>
          <script src='./renderer.js'></script>
        </body>
      
      ////////////////////////////////////////////// renderer.js
      const btn = document.getElementById('btn')
      const filePathElement = document.getElementById('filePath')
      
      btn.addEventListener('click', async () => {
        const filePath = await window.electronAPI.openFile()
        filePathElement.innerText = filePath
      })
      
      ////////////////////////////////////////////// preload.js
      
      const { contextBridge, ipcRenderer } = require('electron/renderer')
      
      contextBridge.exposeInMainWorld('electronAPI', {
        openFile: () => ipcRenderer.invoke('dialog:openFile')
      })
      
      ////////////////////////////////////////////// main.js
      
      const { app, BrowserWindow, ipcMain, dialog } = require('electron/main')
      const path = require('node:path')
      
      async function handleFileOpen () {
        const { canceled, filePaths } = await dialog.showOpenDialog()
        if (!canceled) {
          return filePaths[0]
        }
      }
      
      function createWindow () {
        const mainWindow = new BrowserWindow({
          webPreferences: {
            preload: path.join(__dirname, 'preload.js')
          }
        })
        mainWindow.loadFile('index.html')
      }
      
      app.whenReady().then(() => {
        ipcMain.handle('dialog:openFile', handleFileOpen)
        createWindow()
        app.on('activate', function () {
          if (BrowserWindow.getAllWindows().length === 0) createWindow()
        })
      })
      
      app.on('window-all-closed', function () {
        if (process.platform !== 'darwin') app.quit()
      })
    • ipcRenderer.sendSync

      • The ipcRenderer.sendSync API sends a message to the main process and waits synchronously for a response.
      • The structure of this code is very similar to the invoke model, but we recommend avoiding this API for performance reasons. Its synchronous nature means that it'll block the renderer process until a reply is received.
      • Returns any - The value sent back by the ipcMain handler.
      • Send a message to the main process via channel and expect a result synchronously.
      •   // renderer process
          const result = ipcRenderer.sendSync("some-event");
        
        
          // main process
          ipcMain.on("some-event", (event) => {
          ...
        
          return event.returnValue = true;
          })
  • Pattern 3: Main to renderer

    • ipcRenderer.on via preload
      • We don't directly expose the whole ipcRenderer.on API for security reasons. Make sure to limit the renderer's access to Electron APIs as much as possible. Also don't just pass the callback to ipcRenderer.on as this will leak ipcRenderer via event.sender. Use a custom handler that invoke the callback only with the desired arguments.

Message Ports

  • allow passing messages between different contexts. It's like window.postMessage, but on different channels.
  • In the renderer, the MessagePort class behaves exactly as it does on the web. The main process is not a web page, though—it has no Blink integration—and so it does not have the MessagePort or MessageChannel classes. In order to handle and interact with MessagePorts in the main process, Electron adds two new classes: MessagePortMain and MessageChannelMain. These behave similarly to the analogous classes in the renderer.
  • MessagePort objects can be created in either the renderer or the main process, and passed back and forth using the ipcRenderer.postMessage and WebContents.postMessage methods. Note that the usual IPC methods like send and invoke cannot be used to transfer MessagePorts, only the postMessage methods can transfer MessagePorts.
  • By passing MessagePorts via the main process, you can connect two pages that might not otherwise be able to communicate (e.g. due to same-origin restrictions).
  • //////////////////////////////////////////////// main.js
    
    const { BrowserWindow, app, MessageChannelMain } = require('electron')
    const path = require('node:path')
    
    app.whenReady().then(async () => {
      // Create a BrowserWindow with contextIsolation enabled.
      const bw = new BrowserWindow({
        webPreferences: {
          contextIsolation: true,
          preload: path.join(__dirname, 'preload.js')
        }
      })
      bw.loadURL('index.html')
    
      // We'll be sending one end of this channel to the main world of the
      // context-isolated page.
      const { port1, port2 } = new MessageChannelMain()
    
      // It's OK to send a message on the channel before the other end has
      // registered a listener. Messages will be queued until a listener is
      // registered.
      port2.postMessage({ test: 21 })
    
      // We can also receive messages from the main world of the renderer.
      port2.on('message', (event) => {
        console.log('from renderer main world:', event.data)
      })
      port2.start()
    
      // The preload script will receive this IPC message and transfer the port
      // over to the main world.
      bw.webContents.postMessage('main-world-port', null, [port1])
    })
    
    //////////////////////////////////////////////// preload.js
    
    const { ipcRenderer } = require('electron')
    
    // We need to wait until the main world is ready to receive the message before
    // sending the port. We create this promise in the preload so it's guaranteed
    // to register the onload listener before the load event is fired.
    const windowLoaded = new Promise(resolve => {
      window.onload = resolve
    })
    
    ipcRenderer.on('main-world-port', async (event) => {
      await windowLoaded
      // We use regular window.postMessage to transfer the port from the isolated
      // world to the main world.
      window.postMessage('main-world-port', '*', event.ports)
    })
    
    ////////////////////////////////////////////////// index.html
    
    <script>
    window.onmessage = (event) => {
      // event.source === window means the message is coming from the preload
      // script, as opposed to from an <iframe> or other source.
      if (event.source === window && event.data === 'main-world-port') {
        const [ port ] = event.ports
        // Once we have the port, we can communicate directly with the main
        // process.
        port.onmessage = (event) => {
          console.log('from main process:', event.data)
          port.postMessage(event.data.test * 2)
        }
      }
    }
    </script>

Chromium Content Module

  • The easiest way of thinking about the Content Module is to consider what it doesn’t do. The Content Module doesn’t include support for Chrome extensions. It doesn’t handle syncing your bookmarks and history with Google’s cloud services. It doesn’t handle securely storing your saved passwords or automatically filling them in for you when you visit a page. It doesn’t detect if a page was written in another language and subsequently call on Google’s translation services for assistance. The Content Module includes only the core technologies required to render HTML, CSS, and JavaScript.

Misc.

  • execute shell script

    const { exec } = require("child_process")
    const os = require("os")
    let command
    let filePath = path.join(__dirname, "test.pdf")
    if (os.platform() === "win32") {
      command = `start "${filePath}"`
    } else if (os.platform() === "linux") {
      command = `xdg-open "${filePath}"`
    } else {
      command = `open "${filePath}"`
    }
    
    // Execute the command
    exec(command, (error, stdout, stderr) => {
      if (error) {
        console.error(`Error: ${error}`)
        return
      }
    
      console.log(`stdout: ${stdout}`)
      console.log(`stderr: ${stderr}`)
    })