Guy with notebook

December 8, 2021

How to create a cross platform desktop app using Electron js?

Patryk

Patryk Kluczak

Frontend Developer
SHARE
frontend app(3)
web development(1)

Introduction

Hello! Welcome again to another article all about our favourite programming language - great and powerful JavaScript. In the previous article, I showed how to create a mobile-native application. Now is time for a desktop app. Make yourselves comfortable: today, I want to show you how to do it using JS. Twist and shout, are you ready? Let's go.

Short history brief

The history of Electron.js started in January 2013. Creators of Electron.js were seeking a solution for a cross-platform text editor where users can work with technology like JS, HTML, and CSS. This is how Atom came into the world. Why not Electron? All in good time. One year later, when the project license was changed to open-source, the name was changed to Electron js.

The main Electron properties

Electron js uses such web technologies as HTML, CSS and JavaScript, using well-known npm modules. Its system files are based on the Node.js API and V8 engine.

As you see, Electron.js uses npm modules so you can install an external extension like on the web application. Furthermore, you can use JS frameworks such as React, Vue, or Angular. Sounds good? I'll show you how to do this, read ahead.

If you have ever created desktop applications, you know how much time it takes to simplify packaging, installation, updates, support for native menus, notifications, dialogue boxes, and optimization of application crash reports.

If you know this pain, you will appreciate that Electron does it for you for any operating system.

Difference between Electron app and Web app

You might be wondering: what is the difference between standard web applications an Electron apps. We use Node.js and V8 engine as well.

While using Electron, we have access to the additional functionality not available from the browser level, such as creating, editing, removing, overwriting files that we will use for our simple app.

Let's start creating our Electron app

Enough this basic information let's start creating our first electron app.

In the beginning, I wrote about the possibility of using a popular JavaScript framework. To create our app we will use React. In this part of the article, I will show you how to do it.

The first step will be installing react app, it's very simple we use a standard React boilerplate.

I think you know the console command, but just to make sure:

npx create-react-app image_reducer

The next step is the installation of the Electron js framework in the application folder. The folder was created by the previous command. On console put:

npm install --save-dev electron

Congratulations! You have just created your first Electron app. Now we should install a few plugins to make it work better.

The first one will be a plugin that allows us to check the development mode:

npm install electron-is-dev

The second one will be an extension for the start and build process.

npm wait-on

Some developers don't like this extension and prefer other solutions but for our app, this plugin will be enough.

We have all the necessary plugins, Now we have to create a file to keep the main electron settings.

I suggest creating a folder in the "public" folder created by React boilerplate. I recommend choosing this place because the Electron builder needed this file for the correct building. Of course, we can change the path and configure our own process in webpack and change package.json as we want but if it is your first electron app, trust me that will be easier.

My path looks like public/electron.js

Basic electron config

We have already dealt with the basic Electron config. In the following part, we will modify and add some functionality. For now, just copy and paste. We will edit this file later.

const { app, BrowserWindow } = require("electron");
const path = require("path");
const isDev = require("electron-is-dev");

function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 1024,
    height: 1024,
  });

  mainWindow.loadURL(
    isDev
      ? "http://localhost:3000"
      : `file://${path.join(__dirname, "../build/index.html")}`
  );

  isDev && mainWindow.webContents.openDevTools();
}

app.whenReady().then(() => {
  createWindow();

  app.on("activate", function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
});

app.on("window-all-closed", function () {
  if (process.platform !== "darwin") app.quit();
});

The code is simple but I want to explain some fragments. The basic Electron behaviour is very simple - wraps the content previously render by webpack or shows the static content.

This approach allows us to work in dev mode without building every time when we change code. The solution is simple. but it is very useful and it also saves a lot of time.

Package.json config

If you did everything right and your project still doesn't want to work properly, no worries. It's time to add some changes to the package.json file.

We will add some conditions and extra params. The most important thing is to modify the "main" parameter the correct path in my case looks like

"main": "public/electron.js

If you decided to use another place for basic Electron settings, it is the right time to put the correct path. Please remember: if you do not want to configure your own builder settings, it would be easier and better to use my path. The native Electron builder starts to compile code from this path and if the electron.js file does not exist, the compiler will throw an error.

Changing the icon is voluntary, I've got a logo from the UI team so I have to use it :) On this occasion, I show you how to do it. In the package.json file we have to add:

 "build": {
    "appId": "Image reducer",
    "win": {
      "icon": "build/icon.png"
    },
    "mac": {
      "icon": "build/icon.png"
    }
  },

Now we will focus on the comments that allow us to start and build our app. As previously, we will modify the package.json file and scripts part.

After creating a project using npx, you should have a basic React conf which we are going to modify.

"electron-build": "electron-builder",
"release": "yarn react-build && electron-builder --publish=always",
"build": "yarn react-build && yarn electron-build",
"start": "concurrently \"cross-env BROWSER=none yarn react-start\" \"wait-on http://localhost:3000 && electron .\""

The build command is very simple. In the first step, we build our React app then we wrap the build in Electron wrapper.

The start command is more interesting because we use the external extension "wait-on" and we wrapper our local env and show it in the Electron framework. As you can see, we use a standard port for React app, so it means that you can open the app in the standard browser as well - of course, if you do not use the Electron and Node possibility.

Ok after these operations you should be able to run the first electron app. In console just put command

yarn start

Electron.js_electron app

Familiar look, right? It’s a react default template but open on electron environment.

For sure I am sharing the file below :)

{
  "description": "Image reducer app created by React and Electron",
  "author": "Kluczak Patryk",
  "build": {
    "appId": "Image reducer",
    "win": {
      "icon": "build/logo512.png"
    },
    "mac": {
      "icon": "build/logo512.png"
    },
    "asar": false
  },
  "main": "public/electron.js",
  "homepage": "./",
  "name": "Image-reducer",
  "version": "0.2.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.11.4",
    "@testing-library/react": "^11.1.0",
    "@testing-library/user-event": "^12.1.10",
    "@types/jest": "^27.0.1",
    "@types/node": "^16.7.13",
    "@types/react": "^17.0.20",
    "@types/react-dom": "^17.0.9",
    "@types/react-router-dom": "^5.1.8",
    "cross-env": "^7.0.2",
    "electron-is-dev": "^2.0.0",
    "imagemin": "^7.0.1",
    "imagemin-mozjpeg": "^9.0.0",
    "imagemin-pngquant": "^9.0.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-drag-drop-files": "^2.1.14",
    "react-input-range": "^1.3.0",
    "react-router-dom": "^5.3.0",
    "react-scripts": "4.0.3",
    "slash": "^3.0.0",
    "styled-components": "^5.2.0",
    "typescript": "^4.4.2",
    "web-vitals": "^1.0.1"
  },
  "scripts": {
    "react-start": "react-scripts start",
    "react-build": "react-scripts build",
    "react-test": "react-scripts test --env=jsdom",
    "react-eject": "react-scripts eject",
    "electron-build": "electron-builder",
    "release": "yarn react-build && electron-builder --publish=always",
    "build": "yarn react-build && yarn electron-build",
    "build-electron": "yarn electron-build",
    "start": "concurrently \"cross-env BROWSER=none yarn react-start\" \"wait-on http://localhost:3000 && electron .\""
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "@types/styled-components": "^5.1.14",
    "concurrently": "^5.3.0",
    "electron": "^12.0.0",
    "electron-builder": "22.8.0",
    "wait-on": "^5.2.0"
  }
}

Application content

I decided to create an app that allows us to reduce image size. That will be a simple example of what we can do in electron.

To style components, I used styled-components. In my opinion, it is the best CSS library for React.

I won't bore you with the styling and component making process, instead, I will show the final result.

More than a few minutes later...

Electron.js

Electron Menu

Time for settings owns menu in-app. The basic menu settings have a lot of positions. In our app, we will use only some of them. We don't have the possibility to modify standard menu settings, if we want to manage we have to create the new one and overwrite the basic solution.

It's nothing difficult but you have to remember about few things.

const isDev = require("electron-is-dev");
const isMac = process.platform === "darwin";
const actionsOpen = require("./actions/openFile");

module.exports = function (mainWindow) {
  return [
    ...(isMac
      ? [
          {
            label: "Image Reducer",
          },
        ]
      : []),
    {
      label: "File",
      submenu: [
        {
          label: "Open File",
          accelerator: "CmdOrCtrl+O",
          click() {
            actionsOpen.openFile(mainWindow);
          },
        },
      ],
    },
    ...(isDev
      ? [
          {
            label: "Developer",
            submenu: [
              {
                label: "Toggle Developers Tolls",
                accelerator: isMac ? "Alt+Command+I" : "Ctrl+Shift+I",
                click() {
                  mainWindow.webContents.toggleDevTools();
                },
              },
            ],
          },
        ]
      : []),
  ];
};

First of all, we have to import the Menu class from the Electron library. After that, we have to connect the previously created menu to the app. We can do this in an easy way just by using a few methods available in the menu class.

const mainMenu = Menu.buildFromTemplate(menu);
Menu.setApplicationMenu(mainMenu);

If your app also needs a keyboard shortcode you have to do something like this.

globalShortcut.register("CmdOrCtrl+R", () => mainWindow.reload());

This code allows reload app and globalShortcut we also import from electron lib.

Open file function

For open file we will use the native electron solution the func is really simple. Check the code:

exports.openFile = function (mainWindow) {
  dialog
    .showOpenDialog(mainWindow, {
      properties: ["openFile", "openDirectory"],
      filters: [{ name: "Images", extensions: ["jpg", "jpeg", "png"] }],
    })
    .then(({ canceled, filePaths, bookmarks }) => {
      const convertImage = fs.readFileSync(filePaths[0]).toString("base64");
      mainWindow.webContents.send("file:openResponse", {
        image: convertImage,
        path: filePaths[0],
      });
    })
    .catch((err) => {
      console.warn(err);
    });
};

In this function, I added one extra thing. Is not mandatory but if you will want to display an image on the frontend you have to convert it to the base64.

const convertImage = fs.readFileSync(filePaths[0]).toString("base64");

Save file function

For saving the image in my app is use the external library "imagemin" is not a native solution but allows reduce image and create a folder where the new image will be added.

exports.saveFile = function (obj, mainWindow) {
  (async function (obj) {
    const { path: imagePath, qualityImg } = obj;
    const shrink = path.dirname(imagePath) + "/ImageReduce";
    const qualityPng = qualityImg / 100;

    if (imagePath) {
      await imagemin([slash(imagePath)], {
        destination: shrink,
        plugins: [
          imageminMozJpeg({ quality: qualityImg }),
          imageminPngquant({
            quality: [qualityPng, qualityPng],
          }),
        ],
      });
      mainWindow.webContents.send("file:saveResponse", {
        success: true,
        error: ``,
      });
    }
  })(obj);
};

Link to the plugin and documentation https://www.npmjs.com/package/imagemin

IPC Main module

The ipcMain module is an event emitter. When it is used in the main process, it handles asynchronous and synchronous messages sent from a renderer process (web page). Messages sent from a renderer will be emitted to this module.

IpcMain module demands two things first is a channel, second is an event with or without args.

Don't worry - it will be much easier if I will show you how it could be implemented.

Func without args:

ipcMain.on("file:save", saveFile);

Func with args:

ipcMain.on("file:open", () => openFile(mainWindow));

We have a few possibilities for managing ipcMain. Depending on the situation, we can connect electron to the web view using:

  • ipcMain.on(channel, listener)
  • ipcMain.once(channel, listener)
  • ipcMain.handle(channel, listener)

or for remove connection:

  • ipcMain.removeListener(channel, listener)
  • ipcMain.removeAllListeners([channel]) for more information please check the documentation https://www.electronjs.org/docs/latest/api/ipc-main

IPC Renderer module

I can write almost the same introduction as there is on the IPCMain module. To make a long story short: is a way to communicate asynchronously starting from a renderer process to the main process.

We have a few possibilities to connect the front to the main Electron process. For more information like always redirect you to the documentation https://www.electronjs.org/docs/latest/api/ipc-renderer

In the app I used two:

ipcRenderer.send("file:open") for send request to the main electron process and for receiving data
ipcRenderer.on("file:openResponse", (e, data) => {
    console.log("Data: ",data); 
});

Building Electron app

Now is the time to build cross platform desktop app. The expected result is a two folder first with standard React app, second is an electron app with installer .exe or .dmg depending on the platform and .zip file. If the build was successfully finished you can try to install it on your computer or send it to someone else for feedback.

OK so let's do this. It's really simple just on console put: yarn build

Depending on performance you have to wait a bit to finish. :) That's all.

Short annotation: if your app works on file and after build, you don't have possibilities to write a file, please add to the package.json (in the case if you create your own configuration).

"build": {
    "asar": false
 },

Applications written in Electron.js

As you can see, our application is simple and was meant to encourage you to discover the possibilities of Electron.

If you are curious to see if you can use it to create larger, more advanced applications, check out the list below, these are just a few examples of applications that can be used. Here are some apps which were created with this framework: Slack, Visual Studio Code, Skype, Github Desktop, Visual Studio Code, Twitch, InVision, Messenger, Discord, and of course Atom.

Are you interested in building cross platform desktop apps? Contact us and ask for a free consultation on app development.

What do you think? Share your impressions!

Meet the author

Patryk

Patryk Kluczak

Frontend Developer

I am JS Developer, specialize in creating stucture and implementing solution for multiplatform app.

Still have some questions?

If you have any questions, feel free to contact us. Our Business Development Team will do their best to find the solution.