Electron Course - Code Desktop Applications (inc. React and Typescript)
Table of contents
- Build powerful cross-platform desktop apps with Electron, React, and TypeScript to elevate your web development skills.
- Communication between processes is key in Electron apps, and the IPC event bus is your bridge to seamless data exchange.
- Focus on building your project without unnecessary costs. Keep it simple and free!
- Configuring your Electron app is all about ensuring the right paths and dependencies are set up correctly for smooth operation.
- Simplifying your project setup can unlock powerful features and save you from future headaches.
- Simplifying your TypeScript project setup can save you time and headaches—just strip away the types, configure your build, and keep your focus on development.
- Building cross-platform apps is powerful, but remember: each OS has its quirks. Always tailor your setup for the system you're targeting to avoid headaches down the line.
- Creating a fully functional Electron app means you can run it anywhere without extra installations, but remember, bundling Chrome makes it hefty. Embrace the trade-off for a seamless user experience!
- Streamline your development process by combining commands into one, making it easier to run your Electron app and front-end simultaneously.
- Mastering async functions can transform your coding game—turn callbacks into promises for cleaner, more efficient code.
- Mastering system data retrieval is key to optimizing performance and efficiency.
- Build a secure bridge between your app and UI with a preload script to control access and protect your system.
- Creating a secure bridge between your UI and backend in Electron is key to limiting access while still enabling functionality.
- Preload your Electron app wisely: expose only what you need to the UI for better security and functionality.
- Mastering Electron means mastering the details—like ensuring your preload scripts are included in the build for seamless communication between processes.
- Understanding your app's file structure is key to managing resources effectively.
- Mastering IPC in Electron means seamless communication between your UI and backend, making data flow effortless and efficient.
- Type safety in coding isn't just a best practice; it's a game changer for building reliable applications.
- Type safety is the key to seamless communication in your code—define your types and watch everything fall into place.
- Type safety is key in coding; it prevents errors and confusion by ensuring data types match perfectly across your application.
- Streamlining your code can transform complexity into clarity, making it easier to manage and less prone to errors.
- Type safety is the key to seamless communication between your backend and frontend; it ensures that every piece of data is exactly what you expect, eliminating errors before they happen.
- Creating a type-safe connection between your front end and back end not only prevents errors but also streamlines your development process.
- Validation is key to securing your app; always ensure events come from trusted sources to prevent malicious attacks.
- Validation is key to securing your app; start simple and build complexity as needed.
- Always clean up after your components to avoid memory leaks and unexpected behavior.
- Transforming raw data into stunning visuals can elevate your user experience from basic to breathtaking.
- Transform your data flow by creating a custom hook that keeps your statistics in check, ensuring you always display the latest insights without clutter.
- Transform your data into visual insights that tell a story, and watch your numbers come to life.
- Maximize your data visualization by ensuring your charts always display the right number of points for a clean, flowing graph.
- Strengthen your app's security by implementing a solid content security policy; it’s your first line of defense against data injection threats.
- Simplifying your content security policy can enhance app security while keeping functionality intact.
- To create stunning icons for your app, remember: use the right resolutions and leverage template features for Mac to ensure your icons always look sharp and professional, no matter the background.
- Mastering the art of cross-platform icon management in Electron is key to creating a seamless user experience.
- Understanding event order in Electron is crucial: know when to close your app and when to keep it running.
- Mastering app behavior on different operating systems is key to a seamless user experience.
- Simplifying your code structure not only enhances readability but also boosts functionality—like turning your app into a tray icon with just a few clever tweaks.
- Creating a custom menu in your app is all about understanding platform differences and leveraging them for a seamless user experience.
- Creating a Custom Menu in Electron
- Simplify your app's menu for a cleaner user experience while keeping essential features accessible only in development mode.
- Simplifying your app's menu can streamline user experience, but remember to keep essential functionalities accessible.
- Customizing your app's window frame can elevate its functionality while giving it a unique look. Don't settle for default—make it yours!
- Designing a sleek app header is all about clean styling and intuitive functionality. Keep it simple, make it draggable, and ensure your buttons are responsive without cluttering the interface.
- Empower your front end to command the back end without waiting for a response—just send the action and let it flow.
- Mastering Electron means mastering communication—between front end and back end, and between tests and features.
- Focus on the backend testing strategy: prioritize unit tests for unsupported features and streamline your end-to-end testing setup.
- To run your Electron app smoothly, always ensure your web server is up and configured correctly before launching tests.
- Testing your Electron app just got easier! By properly handling promises and ensuring your UI elements are ready, you can eliminate flaky tests and boost reliability.
- Stability in testing is key; ensure your setup is solid to eliminate flickering and achieve reliable results.
- Testing is all about creating the right environment; mock what you need, ignore what you don’t.
- Mocking is the key to effective unit testing; it allows you to isolate and validate functionality without the overhead of running the entire application.
- Testing your code is like a safety net for your logic—make sure every function does what you expect before it hits the real world.
- Testing your code is just as important as writing it. A clean UI is the cherry on top, but solid functionality is what truly makes your app shine.
- Clean code leads to cleaner designs. Simplify your structure for better maintenance and aesthetics.
- Designing with intention transforms chaos into clarity.
- Transforming your app's UI can elevate the user experience from basic to brilliant with just a few tweaks.
Build powerful cross-platform desktop apps with Electron, React, and TypeScript to elevate your web development skills.
Welcome to this comprehensive course on building robust desktop applications using Electron, React, and TypeScript. Throughout this course, you'll learn everything from setting up your development environment to implementing advanced features like data visualization, secure communication, and custom window frames, all while ensuring a seamless user experience.
This course has been developed by Nicholas Zimmerman. Hey there! I'm Nicholas Zimmerman, a full-time software engineer from Germany, and I absolutely love web development. This passion is why I'm so excited about tools like Electron, which allow me to combine my web development and Node.js skills into one singular cross-platform app that will run on Windows, Mac, and Linux. Today, we are going to build an app inspired by the Windows Task Manager performance tab, which will serve as a resource manager displaying graphs about your CPU, RAM, and storage usage.
Before we dive into the app development, let's take a look at how Electron actually works and why you might want to use it. So, what is Electron? As I mentioned earlier, it's a tool to build cross-platform desktop apps using JavaScript, HTML, and CSS, or alternatively, something like React to replace these three technologies. Some popular apps that already use Electron include Discord and Visual Studio Code, which you probably already know exist.
Now, how does Electron accomplish all of this? Let's visualize it. Imagine a big blue box representing our app. We, of course, need a way to open up a window. Electron refers to these as browser windows, which are essentially a bundled browser that can display our HTML, CSS, and JavaScript. Electron creates this window by bundling a whole version of Chromium. So, every time you download Discord, VS Code, or any other Electron app, you are actually downloading a complete Chromium browser that Electron controls under the hood to display and manage windows.
However, rendering is not enough; we also want information about the system, such as how much RAM we are using. This can be achieved using the main Electron process, which is essentially a Node.js app. When you start your Electron app, this main process boots up and performs tasks like creating and interacting with windows. It handles tasks such as opening windows, reacting to windows being closed, and closing windows itself. You can even create multiple windows if needed; however, in our case, we will only need one.
Moreover, Electron can do much more, such as interacting with system APIs. These APIs could be used to create icons in the system tray, like the ones you see on the right on macOS or in the bottom right of the Windows taskbar. The most important aspect for our use case is that it is just a Node.js app, allowing it to use Node modules. This means you can leverage most of the things hosted on npm, as well as built-in Node functionalities like the fs module, the OS module, or even something like Express if you wanted your Electron app to host a web server.
In our case, we will use some helper libraries to gather more information about our system, such as memory usage. While most of this could also be achieved using Node's standard libraries, it is crucial to note that there are separate processes involved. You can imagine a thin line separating the main process from the renderer process, meaning that the main process cannot directly use code from the renderer process and vice versa.
So, how can we actually get data to and from the Electron app using the browser window? There is a mechanism in between these two processes, known as the IPC event bus. This is a small process that allows both the main process and the window processes to publish events under a certain name with some data and then listen for these events to retrieve data. For example, if the window publishes a request to delete object number 42, the main process can notify every window that object 42 was deleted, prompting them to update their UI accordingly. This is fundamentally how these two different processes can communicate with each other.
Communication between processes is key in Electron apps, and the IPC event bus is your bridge to seamless data exchange.
In this discussion, we explore how to effectively transfer data between the Electron app and the browser window. Each other, so this guy can't use code from this guy and vice versa. To achieve this, we utilize the IPC event bus, which is essentially a small intermediary process that enables communication between the main process and the window processes.
This IPC event bus allows both processes to publish events under a specific name along with some data, and then listen for these events to receive data back. For instance, you could imagine the window publishing an event saying, "Hey, I want to delete object number 42." In response, the main process would notify every window, "Hey, 42 was deleted; please update your UI so that it isn't displayed anymore." This is fundamentally how these two distinct processes can communicate with each other.
Now that we understand how Electron facilitates this communication, let's delve into the technologies we will be using to develop our app and subsequently convert it into an executable file. Electron actually can't turn stuff into exe, dmgs, msis, app images, etc., so we need an additional tool for that.
To visualize our tech stack, we will be using TypeScript for both the front end and the back end. We will separate the back end and the front end into two distinct stacks. On one side, we have Electron, and on the other side, we have React. The question then arises: how are we going to transform all of this into a fully-fledged Electron app?
First, we need to bundle our React app to produce HTML files. For this purpose, we will use V, a bundler that allows us to efficiently create HTML from our React components, resulting in a fast and compact output, which is ideal for our use case. Next, we will need to bundle everything into a finished Electron app, and for that, we will use Electron Builder. This tool is one of many that can convert your standard Node.js Electron app into a fully functional executable file, whether it be an exe, app image, or whatever your specific system requires.
With Electron Builder, we can write our Electron and React app in TypeScript, convert the React app into HTML using V, embed that into Electron, and then build everything together to create a runnable project. Honestly, don't worry if you don't understand all of this yet. Our next step will involve building a foundational setup that will establish our React app, convert it into HTML, embed it into our Electron process, and finally compile this Electron process into an application that can run on your system—be it an exe, DMG, or app image, depending on your operating system.
Once we have completed this foundational setup, everything should become much clearer. If you still have questions, you can always revisit this part of the course after developing the actual application to gain a better understanding using your newfound knowledge. After establishing this foundation, we will proceed to develop the necessary components for the resource manager and implement some developer experience improvements.
Before we start, I would like to add a small disclaimer. Your operating system will likely display a warning when you attempt to open the built app we are going to create. This warning indicates that the app wasn't created by a trusted developer. This occurs because, for the operating system to trust you as the developer, you need to add a code signing certificate to your app, which typically requires a purchase.
Although your app will still open without issues if the user clicks "I trust this app," many production apps, especially open-source ones, go through this process. I mention this because I want to avoid introducing any costs into this course. Therefore, we will not cover code signing certificates, as they can only be obtained with money, and the process varies based on how you acquire it and the operating system you are targeting. If you want to eliminate this warning, you will need to research the specific process for your users. However, if you are indifferent to this warning, you can simply follow this course, and everything will proceed smoothly.
Now that we are in our IDE, we can begin by creating our UI. Let's run npm create V . in an empty directory. The dot signifies that we want to create the project in the current directory.
Focus on building your project without unnecessary costs. Keep it simple and free!
Especially open source ones actually do that, but I just wanted to mention this because I don't want to add anything into this course that you need to pay for. So, we will not go into code signing certificates because those can only be obtained with money, which is something that I just don't want to add into this course. Depending on how you get it and what operating system you're targeting, the process would differ anyway. So, just look up the process that you need for your users if you want this warning to disappear. And yeah, if you just don't care, then that's even better; you can just follow this course and everything will be just fine.
Now that we're in our IDE, we can actually start by creating our UI first. So, let's run npm create V . in an empty directory. The dot just says, "I want to create our V project right in the directory I'm currently in." Then, we'll choose React because we, of course, want the React project in TypeScript. Now, we just need to run npm install; this shouldn't take up much time, so let's just talk over it real quick. As you can see, the files were already created right here, and now we've got our node_modules, and we can hit npm run dev to actually start our project.
Now, if we just control-click onto this link right here, we can see our React project. However, we might want to optimize this a bit because right now everything is directly in the source directory. Of course, we'll also have some Electron code, which will also live in the source directory. So, let's try to split this up a bit by creating a UI folder in here and basically moving everything that's currently in the first directory into that UI folder.
Now, just doing this will, of course, not work yet because V needs to know where our code is. So, let's just head into the index.html, which is basically the entry point for V to know where all your files are. Here, we can see we've got a script tag that references source/main.tsx, and of course, this needs to be source/i/main.tsx now. If you just hit npm run dev again, we should be able to see that everything is still working just fine.
But, of course, we've also got this public folder right here, which we don't really need right now either because we're not going to have an FV icon or whatever if we basically embed our app inside of a desktop app. So, let's just get rid of this thing right now by removing it and also heading into the app.tsx right now because it also references it. We'll be removing this reference inside of it. Now, if we just look into a w again, we should be able to see that our V app is still fully functional even though we, of course, removed the FV icon and this icon right here.
Now, let's also change some stuff about the build. If I just run npm run build right now, we should be able to see that it creates a dist directory that contains all of our output, so basically the finished HTML and JavaScript. This is good in some ways, but for our use case, it isn't as good because Electron Builder will also create a dist directory, so these will conflict, which we don't really want. Let's just change the V config a bit more by saying build and our build will have an outDir, so basically where do I want to put my finished output? This will just be this-react just so we know where our code is.
Now, let's just quickly remove the current dist directory, run npm run build again, and now we should be able to see that this-react is created and it still contains all of this normal code right here. Of course, right now it's actually tracked by git, which you don't really want, so let's also head into the .gitignore and add this react into the ignored folders just so you never commit it because that wouldn't really make sense.
To now combine this UI with our Electron app, we, of course, need to first of all install Electron. So, let's just run npm install -D electron. We can actually install Electron as a dev dependency because it isn't needed in the final bundle of our app. The exe or whatever it is only needed to build it, so even though we will be importing stuff from Electron, it isn't actually required to install it as a normal dependency. A dev dependency is plenty.
Do also make sure that you set type module in your package.json. This should be set automatically by V, but sometimes it isn't for specific V versions, so just make sure that it is set because otherwise, we can't use ES module syntax instead of our Electron code, which we will need to do.
Now, let's actually get into configuring our Electron app. So, let's create a new folder inside of our source directory called electron, and in here, we'll add a new file called main.js. We will turn this into TypeScript later on, but for now...
Configuring your Electron app is all about ensuring the right paths and dependencies are set up correctly for smooth operation.
To begin with, we need to bundle our app so that the executable, or whatever it is, only needs to be built once. Even though we will be importing stuff from Electron, it isn't actually required to install it as a normal dependency. A dependency is plenty. Additionally, do make sure that you set type module in your package.json. This should be set automatically by Vite, but sometimes it isn't for specific Vite versions. So, just make sure that it is set because, otherwise, we can't use ES module syntax instead of our Electron code, which we will need to do.
Now, let's actually get into configuring our Electron app. First, let's create a new folder inside of our source directory called electron. In this folder, we will add a new file called main.js. We will turn this into TypeScript later on, but for now, using JavaScript is easier. In this file, we can just import from Electron, and we will need two things: first of all, the app and the browser window.
Here, we can interact with the app. As soon as we import it, Electron will do all of its magic to create an app that we can interact with. We can say app.on
to wait for an event on the app, and once the app is ready, we want to run this arrow function. At this point, we can say, "okay, the app is ready, now let's create a window." This window will be our main window, and it is a new browser window. This browser window can be configured in many ways; for now, we'll just add an empty config object and leave it at that.
Next, we can say mainWindow.loadFile
, and the file that we are going to load is, of course, our index.html file inside of the dist/react directory. You can imagine that people will have this project in different directories on their computers. Once you distribute it, you can't be sure that it's under whatever directory your code is under; it definitely won't be. People could have it under Program Files on Windows, or Applications on a Mac, or maybe even in the Downloads folder if you distribute it using an executable with nothing else.
Therefore, you need some way to know where your project is currently running from, and the app object actually gives you a helper for that, which is called app.getAppPath. Now, we just need to append /dist/react/index.html to this, and everything seems fine. However, this is not entirely correct because these slashes only work on Mac and Linux, while Windows requires backslashes for paths. Fortunately, we don't need to handle this ourselves because Node already includes a nice module called path. You don't need to install this because it's already included in the standard library.
What this module can do is handle all of this path magic for you. You can just run path.join
, and we can say we want to join our app path with /dist/react/index.html. Now, we also need a way to run this as well, so let's configure that by heading into our package.json. First of all, we need to tell it where our main script is. This main attribute is basically telling Electron which script to run when booting up, and this will be source/electron/main.
Our main file will run once Electron starts, and of course, we will need a dev script for that as well. Let's rename the first dev script we got to dev react and add another one called dev electron, which will just run electron . to start an Electron process in the current directory and take this main script. Now, let's try that out real quick with npm run dev electron
. As you can see, we got a blank window.
Why is that? We can just head to the View menu on the Mac, or if you're using Windows, this should be in a menu bar right under the top bar. We can run View > Toggle Developer Tools to see the normal developer tools and get our error. We can see it didn't find a file. What did it try to request? Let's reload real quick, and you can see it tried to get file://assets/index. That isn't what we need because the assets directory isn't at the root of our computer, which is what it is trying to access right now. It is actually in a relative position to our index.html.
If we check the index.html, we can see it tries to require /assets/index, which is exactly what we saw. However, on a Mac, a slash means the root of the file system, which is not what we want. We actually want /assets in both of these positions. So, how do we actually solve this? It's quite simple. We can go into our vite.config and set the base path of the project to / instead of /assets/. Now, if we just run npm run build
again to recreate this index.html file, we should be good to go.
Simplifying your project setup can unlock powerful features and save you from future headaches.
Let's go through the process of configuring our project step by step. First, we need to address an issue with the file path. When we tried to request the assets, it attempted to access file assets index, which isn't what we need. The assets directory isn't located at the root of our computer; instead, it is in a relative position to our index.html file.
If we look at the index.html file, we can see it tries to require SL assets SL index, which is exactly what we observed. However, on a Mac, the slash denotes the root of the file system, which is not our intention. We actually want /Assets in both of these positions.
To solve this, we can simply go into the v config and set the base path of the project to / instead of Slash. After making this change, we can run the command npm run build again to recreate the index.html file. Once that is done, we can run the electron app again, and after a few seconds, we should see that our UI is now fully loaded inside the window. Furthermore, all the react state is still functioning, allowing us to have a local electron app that utilizes our local UI and local state, all while being fully interactive with React. This creates an awesome little window for us.
Next, let's focus on configuring the project with TypeScript to prevent potential errors in the future. Fortunately, converting everything to TypeScript is quite simple, especially since we already have TypeScript installed. As we can see, the V project already contains a TS config. However, we will not use this TypeScript config because it expects frontend code, which we do not have in our electron app; it is primarily a Node project.
The first step is to tell the project, specifically the V project, to exclude Source SL electron. This means we will define our own TypeScript config for the electron code. To do this, we will navigate to the electron directory and create a new file named tsconfig.json. This will allow us to specify what TypeScript should do with the files inside the electron directory.
Next, we will rename the main.js file to main.ts and define a simple type, such as type test = string, to ensure we have some TypeScript code that would generate an error if it were run using JavaScript, as JavaScript does not recognize types. We will then add some configuration to this file. This configuration will guide the TypeScript compiler on how to handle our code and what to expect from it.
The configuration will enable strict mode, which is essential for all new projects because it provides null safety and other helpful features. When creating a new project, it is advisable to set strict mode to true. Next, we will instruct TypeScript to create ES module syntax, which means using import and export instead of require and module.exports. This is necessary because Electron expects us to use ES modules in the latest versions, especially since our V project indicates that it is a module.
We will also specify that our code will be written in ES module syntax by using module. Essentially, we are informing TypeScript that we are creating a Node project using ES Next, and we want it to convert our ES module code to ES module code without altering anything, except for removing the types.
Additionally, we need a location to store our generated JavaScript files. TypeScript will copy our TypeScript code, remove all the types, and paste it elsewhere. This will be two folders up from our tsconfig. One folder would be source, and two folders would place it on the same level as the React code.
We will also configure TypeScript to ignore errors from dependencies. This means that if any of our dependencies do not comply with these rules, such as not using strict mode, we still want to be able to build our code. To achieve this, we will instruct TypeScript to ignore whether the libraries support all of our TypeScript recommendations, as they are JavaScript anyway. Thus, we can use them as they are.
Simplifying your TypeScript project setup can save you time and headaches—just strip away the types, configure your build, and keep your focus on development.
To set up our project, we will basically just copy our TypeScript code, remove all the types, and paste it somewhere else. This will be located two folders up from the RTI config. So, one folder would be sorus, and two folders will be on the same level as the dist react code. As you can see, I've already played around a bit, and we can actually see this electron right here.
Next, we will also need to ignore errors from dependencies. This means we will skip the lip check, which will say that if any of our dependencies don't match the rules specified, such as not using strict mode, we still want to be able to build our code. For this to work, we need to tell TypeScript, “Hey, don’t check if the libraries support all your TypeScript recommendations.” After all, they are JavaScript anyway, so let's just ignore all of that and use them as they are. You wouldn’t want your project to not build if you just have a dependency that doesn’t support what TypeScript expected.
Now, we will need to tell TypeScript to run this config. To do this, we will add another script to the package.json, which will be transpile electron. This script will run the TypeScript compiler using the project we just defined, specifically the Source electron TS config. This will compile all TypeScript files inside the electron directory, which is a really simple way to handle things.
Let’s try it out. I will just remove my existing dist electron directory and run npm run transpile:electron
. After saving, we should see that when it runs, our new directory is created, which is up here in the electron folder, and it contains our main.js. Now, we also need to tell Electron that it should run the generated file and not the source directory. This is really easy; we will just change the main bit from Source/electron to dist/electron/main. Now, if we just run npm run def:electron
, we should see that everything is still working, even though we now have TypeScript code. If we head into our main.js, we can see it’s basically the same code as before, just without the types we defined.
There’s just one more thing we need to do to ensure we don’t commit anything we don’t want. We need to exclude this electron from our git tracked files by heading into the .gitignore and doing the same as we did with dist react, which is adding electron into here and saving it. Now our electron project can just use TypeScript without us needing to worry about anything, which is really helpful.
The final step before our app is ready for development is to set up electron-builder. We will run npm install --save-dev electron-builder
. After it’s done, we can create a config file. In many documentations, you will find that people add a build property into the package.json and put the config there. This is fine, but it’s not as easy to differentiate as if you had a separate config for electron-builder.
So, we are going to create a new file called electron-builder.json. This config will contain a few settings, including an app ID. The app ID is a standardized way of naming your app, starting with "com," followed by your company or personal name. In my case, it will be n-cmon, followed by your app name, such as electron course.
Next, we will define what files should be included in our app. This should include our source code, which is the JavaScript compiled from our TypeScript, and our HTML, specifically the dist react folder. There are also three more settings that are operating system specific. For Mac, we want to create a DMG, which is basically an installer for Mac apps. For Linux, we want an app image, which is a portable Linux application that runs on most Linux distros. Since Linux allows you to categorize your app, we will specify that our app is a utility. For Windows, we actually want to create two apps: a portable exe and an MSI installer.
Hey, future Nicholas here! I just realized that there's a minor issue with this configuration. While it works perfectly fine for Mac and Linux builds, there’s a small issue with Windows builds that prevents electron-builder from creating a finished build. For some reason, electron-builder requires you to always set a desktop icon for Windows builds and will not use the default electron icon. You can simply go ahead and specify the icon by saying icon: 'path/to/your/icon'.
Building cross-platform apps is powerful, but remember: each OS has its quirks. Always tailor your setup for the system you're targeting to avoid headaches down the line.
Most Linux Theos and because Linux also allows you to categorize your app so that it can be displayed in a different manner. I also want to say that my app is a utility. On Windows, I actually want to create two apps: a portable exe and an MSI install.
Hey future Nicholas, right here I just realized that there's a really minor issue with this configuration. It works perfectly fine for Mac and Linux builds, but there's a small issue with Windows builds that prevents electron Builder from actually creating a finished build. For some reason, electron Builder will require you to always set a desktop icon for Windows builds and just won't use the default electron icon. You can just go ahead and say icon is do/whatever your icon is called. As you can see, I've created one right here; it should at least be 255x255 pixels and ideally a PNG. The build does actually set this desktop icon for my project right now, even though I'm, of course, on Mac. The build worked before as well, but this should be enough for you to continue following the course. Even though the rest of the course will not show this icon right here, the finished project on GitHub will, of course, contain it just so everything works on every operating system.
Let's move on. Now there's just one more thing we actually need to do here, which is heading back into the package.json and adding the required scripts to it. If we just head back to the scripts section, we can add these, and you can see most of this is actually really similar. First of all, we transp electron, then build our HTML, and then run electron Builder with Mac arm 64. Basically, I want to create a Mac build for arm processors, so an M1, for example. For Windows, all of this is the same; I just say I want a Windows build for x64, so a normal Intel or AMD processor, and the same thing for Linux as well.
Now we could actually already try to run one of these. If you're on Windows, then you might need to run this as administrator because it will need to install some stuff on your system for the first time. If that doesn't work properly, then just try to run it as administrator. So now, let's just go npm run disc Mac, and we should actually see that it can't currently create our project properly because there will be a little tiny error. Even though electron understands this main bit up here perfectly fine, electron Builder will actually expect you to add a file extension here. So let's just call it this-electron-main.js and run this again. If you're still having trouble, then please make sure that the system you're targeting is actually the same type of operating system that you're using right now. If you want to create a Windows build, do try to create it on Windows and not on Mac or Linux. It will most likely work to create a Windows build on all types of operating systems, but sometimes there can be issues with exporting to different operating systems. So just try to use the same one you're exporting for when creating your builds.
All right, so now it's done. It might have taken some time; that's no issue whatsoever. It will download some stuff, compile some stuff, whatever. But now if we just check out, we should have this disc folder right here. For my case, I created a Mac build, so here I will see a Mac arm 64 folder and also my DMGs right here. There are different ways of running this now depending on what you build. On Windows, you should just see an Exe; on Linux, you should just see an app image, and on the Mac, you see a few files, but the most important one is still the DMG. Of course, depending on what you configured here, these things will differ. For example, the portable will create an exe, and the MSI will, of course, create an MSI.
Now, let's test this out. Let's actually head into a folder right here. I'll not install the DMG for now because on Mac there's actually a full version of this thing in the Mac arm 64 folder that I don't need to separately install. I'll just open it, and now we should see that our electron app is booting up just fine. So we've now actually created a fully working electron app as a bundled project that can run on any separate machine without needing to install Node or whatever other software, which is really, really awesome in my opinion.
Now, if you just get some info about this app, we can see it's 240 MB big, and that's because, as I mentioned before, it bundles a whole Chrome browser. This is one of the reasons why many people don't like electron because it creates large apps. Bundling Chrome will take up a lot of space, but for our case, this is totally fine. We just want to create an app with an awesome user experience using our existing knowledge, and this is something...
Creating a fully functional Electron app means you can run it anywhere without extra installations, but remember, bundling Chrome makes it hefty. Embrace the trade-off for a seamless user experience!
We don't need to separately install anything; I'll just open it, and now we should see that our Electron app is booting up just fine. So, we've now actually created a fully working Electron app as a bundled project that can run on any separate machine without needing to install Node or any other software, which is really, really awesome in my opinion.
If we get some info about this app, we can see it's 240 MB big, and that's because, as I mentioned before, it bundles a whole Chrome browser. This is one of the reasons why many people don't like Electron; it creates large apps since bundling Chrome takes up a lot of space. However, for our case, this is totally fine. We just want to create an app with an awesome user experience using our existing knowledge, and this is something we've just done. We've created a simple app using HTML, CSS, and JavaScript, or rather, Electron, React, and TypeScript.
Now, let's get into actually developing the app. But first, let's try to do some DX improvements so our development will be a bit smoother without needing to run multiple scripts to get everything done, like transpiling stuff and all that mess. We'll try to optimize this a bit now. Our most important development experience improvement will be that we don't want to run as many scripts to get developing. To achieve that, we'll actually use V Hot Module Reloading when we're in development mode instead of just building the front end and bundling it inside of Electron. This is because bundling requires us to reload every time we change the front end, which is not really effective.
To implement this, we'll need one dependency: npm i -D cross-env
. This is basically used so that we can set environment variables on both Mac, Linux, and Windows. On Mac and Linux, you can just go ahead inside of your dev Electron and say NODE_ENV=development
and then run electron .
, and the node environment will be set. However, on Windows, you will need this cross-env bit in the front, so it will be cross-env NODE_ENV=development
and then run Electron.
Now we know if we are in development, but how does our code actually know that? Let's create a new function in our Electron code called util.js
, and we'll export a function isDev
from here. This function will basically return a Boolean: it will return process.env.NODE_ENV === 'development'
. So basically, when we're in development mode, this will be true; otherwise, it will be false. This way, we can feature toggle different tools when we're in development or production mode. For example, we can use the V HMR (Hot Module Reloading) server when we're developing, but use a pre-built application when we're in production.
The first step to achieve that goal is to set a fixed port for the V server. We're back in our V config, and we're going to configure our hot module reloading server. The server is going to be on Port 5123. You could choose any port you want, but I just thought that number was nice. We'll also tell it that if the port isn't available, then please warn us because what we're going to do now will only work when exactly this port is being used.
Now, if we head back to our package list and run npm run dev
, we should see that it now opened up on Port 5123. If we check this out, we can see everything worked just fine. Perfect! Now, we obviously also need to get this running. So, let's try to use our changes. If isDev
is true, then we want the main window to load the URL, which is basically the URL with the port we defined for V. So, on a local machine, we will use the V port and give whatever it outputs. If we're not in development, then we want to build with our production files, specifically react/index.html
.
To try it out, let's first go ahead and transpile our Electron app to turn all of these new TypeScript changes back into JavaScript that can run in Electron. Then, let's just spin up our Electron server. We should see that it actually creates a blank screen. Why is that? Well, the V server is currently not running, so http://localhost:5123
doesn't return anything yet. Let's close this Electron app again, go into a second terminal, and start the V server again.
Streamline your development process by combining commands into one, making it easier to run your Electron app and front-end simultaneously.
To begin with, we need to load the URL for our application. This is essentially the URL with the port we defined for V. So, on a local machine, we will use the V port and retrieve whatever it outputs. If we are not in development mode, we want to build with our production files, specifically the react index.html
.
Now, to try it out, let's first transpile our Electron app to convert all of these new TypeScript changes back into JavaScript that can run in Electron. After that, we will spin up our Electron server. Initially, we should see a blank screen. Why is that? Well, the V server is currently not running, so HTTP Local Host 5,123
doesn't return anything yet.
To resolve this, we need to close the Electron app, open a second terminal, and start the V server again. After that, we can return to the main terminal and start Electron once more. Now, we should see that our app is actually running. We can also test the hot module reloading feature by placing the windows side by side and inputting a value. This demonstrates that hot module reloading works, meaning we no longer need to rebuild our front end or restart the Electron app when working on front-end code. We only need to restart when we change the Electron code, which is already a significant DX Improvement.
However, managing multiple terminals is becoming cumbersome, so let's optimize this process a bit. I assure you, this will be the last step before we dive into development. It's such a valuable DX Improvement that I can't help but share it. We will install one last package: npm install --save-dev npm-run-all
, which allows us to run multiple npm commands in parallel while still being able to terminate them all simultaneously using Control + C.
Now, let's make some improvements. Whenever I want to start the development Electron app, I will first compile it. Essentially, when I run dev electron
, I want to execute npm run transpile electron
, and once that's done, I will start the development server using cross-env node development electron
. This way, we have already eliminated one command.
Next, we will create a simple development script that runs npm run all --parallel
, executing all the upcoming commands in parallel. We will run dev react
and dev electron
, ensuring that none of our commands are still running in any of these terminals. Now, when we hit npm run dev
, we should see that both processes spin up simultaneously.
At this point, our Electron app is launched, and we can test the hot module reloading once more by going back into our app, opening app.tsx
, and removing the test input we added. Everything is functioning perfectly, and we can manage both processes simultaneously without the hassle of multiple V servers, which could lead to confusion, especially with strict P setups.
Now, we can simply run one command: npm run dev
. This will spin up our front end, compile our TypeScript, and start the Electron app, all with just one command. We are finally ready to begin development.
First, we need to gather all of the system resources, including CPU usage, RAM usage, and storage usage from our machine. To do this, let's remove the testing type we created earlier and create a new file called resourceManager.ts
to keep everything organized. In this file, we will export a function called pollResources
. This function will handle the polling, which we will initiate to inform the UI of any changes to the resources so that it can update accordingly.
We will define a constant for the polling interval, specifying that we want to update twice every second, or every 500 milliseconds. We can then use setInterval
to execute whatever we place into this function twice a second.
Next, we need to retrieve our data, which requires a few functions and an additional library called OS utils. Let's install this library quickly, but this time not as a dev dependency since we need it in the finished build. We will run npm install os-utils
, which will help us gather our CPU and RAM information.
To start, we will define a function called getCPUUsage
, which will utilize OS utils to fetch our CPU utilization.
Mastering async functions can transform your coding game—turn callbacks into promises for cleaner, more efficient code.
To begin, we need to define a polling interval and we'll say that we want to update this twice every second, which translates to every 500 milliseconds. Now, we can simply use setInterval
, and whatever we put into this function will, of course, run twice a second.
Next, we need to get our data, and for that, we will require a few functions and another library called OS utils. Let's go ahead and install it quickly, this time not as a dev dependency because we actually need it in the finished build. We will run the command npm install os-utils
, which we will use to retrieve our CPU and RAM information.
To start, we will define a function called get CPU usage. This function will essentially call os-utils
, which we need to import. We will use the command import os-utils from 'os-utils'
. However, it can't find a declaration for this module, so we need to install the types as well. We will run npm install --save-dev @types/os-utils
. This step is necessary because OS utils does not bring its own types. If we want TypeScript and full type completion, we need to get the types separately, which are available in @types/os-utils
. This can be saved as a dev dependency since it only contains types that won’t be included in the finished bundle.
Now, we should have good autocomplete functionality, and we can see that there are already many options available. The CPU usage function seems to be working well. This function takes a callback with a percentage, so let's quickly test that by using console.log
to display this percentage. We will set it up so that on every interval, we want to get the CPU usage.
Let's run this by executing npm run dev
to spin up everything, and we should see the output in our console. However, after waiting a bit, we notice that nothing appears. The reason for this is that we haven’t used the poll resource method anywhere. To fix this, we will go into our main.ts
file and start polling the resource as soon as possible.
By importing the necessary functions, we can ensure that as soon as our app is ready and the window is rendered, we will start polling our data. After making these adjustments, we will restart the application and wait a few seconds until everything is ready. Now, we should see that our CPU usage is being logged correctly.
However, I want to return this value from the function instead of just logging it. To achieve this, we can wrap a promise around the function to turn it into an async function. We will return a new promise that takes a callback with the resolve function. When we call the resolve function, the promise will be returned, allowing us to await the function or value passed inside this resolve.
Now, we can say that when os-utils.cpuUsage
calls its callback, we will pass the value from the callback back inside our promise. This means that when os-utils.cpuUsage
calls the resolve function with a percentage, we will receive a promise with that percentage that we can await. We can test this by saying const CPUUsage = await getCPUUsage
, and of course, we will need to make this an async function to use await.
Next, we can log the CPU usage again. After restarting everything, we should see that everything still works the same, but now we have a more modern and easier-to-use function to work with.
Now, let's do the same for our RAM usage, which is even simpler because it is a synchronous function. We can get our RAM usage by calling os-utils.freememPercentage
and subtracting this value from one. Since this function returns a value between 0 and 1, subtracting it from 1 will give us the used memory percentage. For example, if the free memory percentage is 0.3, then 1 - 0.3 will give us 0.7, indicating 70% usage.
We can implement this by saying const RAMUsage = getRAMUsage()
, and then we will log both the CPU usage and RAM usage. After running the application again, we should see our RAM utilization appear in the console as well.
Finally, let's move on to retrieving data about our disk usage. For this, we will use the built-in Node module FS, which will only work starting from Node version 18. If your version is older than that, I recommend updating, as those versions are no longer supported. If you prefer not to update, you can use any other library that can perform the same function.
Mastering system data retrieval is key to optimizing performance and efficiency.
To begin with, we need to calculate the RAM usage. By taking zero and subtracting one from the 3% usage, we can determine the used usage. Essentially, 1 minus 3 gives us the used value. We can implement this by declaring a constant: const RAM usage equals get RAM usage. Next, we will log the object, which includes both CPU usage and RAM usage. After executing this, we should see our RAM utilization appear in the output. Let's wait a moment, and yes, here it is—perfect!
Now, let's move on to retrieving data about our disk. For this, we will utilize the built-in Node module FS, which is only compatible with Node version 18 and above. If your version is older than that, I highly recommend updating, as those versions are no longer supported. Alternatively, if you prefer not to update, you can use any other library available on npm that serves the same purpose. However, we will use FS to simplify our task.
First, we need to import FS. Then, we will define a function, which I will copy for clarity, as it is easier to explain than to code live. This function is called get storage data. Our storage data will utilize fs.statsSync, a function that provides synchronous statistics about a specific region in our file system without needing to await a response. We will target the root file system to obtain the total disk space on our machine.
If we are on Windows, we will specifically check the C drive, where Windows is installed. Conversely, if we are on Linux or Mac, we will access the root of the file system, as everything on Mac begins at /, similar to Linux. Using the data obtained from stats, we can calculate everything else.
To determine our total storage space, we need to know the size of a single block on the disk. Since everything is stored as blocks of a specific size in bytes, we can multiply the size in bytes of each block by the total number of blocks to find out how many bytes our disk has in total.
For the free space on the disk, we will again need to know the size of each block. By calculating the number of free blocks and multiplying that by the space each block occupies, we can ascertain how much space is available on our drive.
To compute the total space on the disk, which we will use later, we will divide our total space by 1 billion to convert it into gigabytes. Dividing bytes by a thousand gives us kilobytes, and dividing by a million gives us megabytes, leading us to gigabytes. We will then use the Math.floor function to ensure we have a singular value without any decimals, for instance, displaying 240 GB instead of 240.267.
Next, to calculate our usage, we will first determine how much percentage is currently free on our disk by taking 3 over total. Then, similar to our RAM calculation, we will subtract that value from 1 to find the space that is currently used.
Now we can retrieve our storage data by declaring const storage data equals get storage data. We can then assign storage usage to storage data.usage. Let’s test this out to ensure that all three values are logged correctly, allowing us to continue working with them. As expected, all values are logged, and the storage usage remains constant for now since no new installations are occurring.
Having gathered all this dynamic data about our system, we may also want some static data such as our CPU name, total memory, and total storage. This information will not change frequently, so we can retrieve it once and display it in our UI for better aesthetics.
To achieve this, we will export another function called get static data. This function will first utilize the previously defined storage data function, assigning const total storage equals get storage data to obtain the total storage in gigabytes. Next, we will retrieve our CPU model by importing the OS module, which is another standard Node module that does not require installation. We will import it using import OS from 'os'. Finally, we will access the CPU information using os.cpus(), assuming there is only one CPU in the system, and we will retrieve the first CPU model.
Build a secure bridge between your app and UI with a preload script to control access and protect your system.
To enhance the user interface (UI) of our application, we will implement a function called getStaticData. This function will first utilize an existing function, getStorageData, to retrieve the total storage available. We will declare a constant, totalStorage, which will be equal to the result of getStorageData(). This will provide us with the total storage in gigabytes.
Next, we need to obtain the CPU model. To do this, we will import the OS module, which is a standard module that does not require installation. We can import it using the statement import Os from 'OS'. Subsequently, we will access the CPU information by calling os.cpus(). For our purposes, we will assume there is only one CPU in the system, so we will retrieve the first CPU and extract its model. This model could be something like Apple M1 or i5-7500, which is useful for displaying in our resource manager.
Additionally, we will gather the total memory in gigabytes. This can be achieved by using os.totalmem(), which returns the total memory in mebibytes (MiB). To convert this value to gibibytes (GiB), we will divide it by 1024. This conversion will allow us to represent memory in a more user-friendly format, such as 8 GB, 16 GB, or 32 GB. We will also apply a floor function to this result to avoid displaying any fractional values.
Once we have gathered the total storage, CPU model, and total memory in gigabytes, we can return these three values quickly from the getStaticData function.
Moving forward, our goal is to display this data within our UI and create an appealing interface around it. To facilitate communication between the backend and frontend, we will utilize an IPC event bus. This bus allows our application to send data to the frontend and enables the frontend to send data back to the application, as well as wait for responses. The communication is bidirectional, utilizing a custom protocol known as the IPC renderer object in our frontend.
However, it is important to note that the IPC renderer object is non-standard and not part of the default APIs in a Chrome browser. Therefore, our application must find a way to send this object to the UI so that it can utilize the IPC event bus. One common approach, albeit insecure, is to set up certain settings and use window.require in our UI. While this method allows access to the IPC renderer, it also exposes every Node module, including potentially dangerous ones like FS. This could lead to security vulnerabilities, such as allowing cross-site scripting (XSS) attacks to interact with the entire file system.
Fortunately, a more secure solution exists in the form of a preload script. This script can be registered to run before our window is initialized, granting it similar permissions to our application. The preload script will append data to the window object in our browser window. We can import electron, retrieve the IPC renderer object, and define a limited object that will be appended to the window object. For instance, we could create window.electron and provide access to a method like window.electron.requestStaticData. This method will serve as a wrapper around the IPC renderer function, allowing interaction with the IPC event bus while restricting access to only the necessary Electron APIs.
In summary, our preload script will encapsulate the IPC bus functionality, enabling the UI to request static data from the backend without exposing the entire range of Electron or system APIs. This approach ensures that users have limited access to only the functions we want them to use, thereby enhancing the security of our application.
Creating a secure bridge between your UI and backend in Electron is key to limiting access while still enabling functionality.
In our UI, we will append an object to the window object, resulting in something like window.electron. This will provide access to a small object, allowing us to use window.electron.requestStaticData. This function could serve as a wrapper around the IPC renderer function, which will interact with the IPC event bus. The goal here is to limit access to the Electron APIs and, even more critically, to the system APIs within our UI. Instead, we will only provide restricted access to the functions that we want the user to utilize.
Essentially, our preload script will create a wrapper around the IPC bus using the IPC renderer. This will be employed to request static data from the backend. We will export this singular function, allowing our UI to use this specific method to retrieve static data. I understand that all of this may sound quite complicated at the moment, given the various scripts involved. It would indeed be much simpler if we could just require everything directly. However, for security reasons, this approach is not feasible. Therefore, we set up a preload script to act as a bridge between our app and UI, enabling us to provide access to the specific content we want the UI to interact with.
This bridge will allow the front end to communicate with the IPC bus to send and receive data from the backend. Don't worry if you don't grasp all of this just yet; we will implement it shortly. Once we have done so, you can refer back to this explanation for clarity, or ideally, you may find that you understand it better after having implemented it yourself.
Now, let's proceed to the implementation phase. To make this as straightforward as possible, we will start by loading a preload script and familiarizing ourselves with how everything operates. This is the initial step we need to ensure that the IPC process is available within our browser window.
To achieve this, we need to modify the settings we apply when creating our new browser window. In this configuration, we can set various parameters, such as the initial width of the window or the title. However, our primary focus is on the web preferences, which dictate the permissions for our web browser.
In this section, we could disable context isolation or enable node integrations, but I must emphasize that these actions can introduce security vulnerabilities. They would allow access to everything in Node.js from the browser window, which is generally not advisable. Instead, we want to add a preload script.
Unfortunately, this requires a path, as it expects a string that points to a file. Since this path will differ between development and production modes, we will create a new file called pathResolver.ts. This file will contain a simple script named getPreloadPath.
Our getPreloadPath function will need to import a few essential modules. First, we will import path to ensure we have a normalized path that works on both Mac and Windows. Next, we will import the electron app. Lastly, we will import the isDev method that we defined earlier.
Now, let's delve into the functionality of this function. We will join a path in a cross-platform manner. Specifically, we will obtain the path of our app using getAppPath, which Electron provides. In development mode, we will add one dot to indicate the current directory, while in production mode, we will need to go up two directories and specify electronD/preload.cjs.
The reason for the .cjs extension is that Electron Builder will bundle all our TypeScript files into a single file within the electron folder. Since we need to load this using the file system, we cannot use the default extension. Instead, we will add this alternative extension and write it in CommonJS format to circumvent this issue. This workaround is also why we go up one directory in the production build. Later, we will instruct Electron Builder to handle this file separately, ensuring it is placed in a different directory.
Now, let's create a new file named preload.ts because we want to use TypeScript and compile it down to .cjs later using the TypeScript compiler. Within this file, we can now implement the necessary functionality.
Preload your Electron app wisely: expose only what you need to the UI for better security and functionality.
This electron preload file is called CJs because the Electron Builder will actually bundle all of our TypeScript files inside of this electron folder into a singular file in the end. However, since we need to load this using the file system, this can't happen. Therefore, we'll just add another extension and write this in CommonJS to basically avoid this issue. This little workaround is also the reason why we're going up one directory when we're in the production build. Later on, we will instruct Electron Builder to handle this file separately, which will cause it to be placed in a different directory.
Now, let's create a new file called preload.ts because we, of course, want TypeScript and will compile it down to JavaScript later using the TypeScript compiler. In this file, we can now do whatever we want. We start by declaring const electron = require('electron');
Remember, we are writing in CommonJS, so this requires the require
keyword. Now, we can proceed with our implementation.
With the electron
variable, we have access to various functionalities, such as a context bridge. This context bridge can be used to bridge data between our Electron process and our main window. We will do this by calling the exposeInMainWorld function. What this function does is append whatever we are adding here to the window object. We want to add an object called electron to our window, which will contain the following content.
We can add just about anything we want to this object. For instance, I will add two methods that are quite relevant to our needs later on. The first method will allow us to subscribe to our statistics, meaning our backend will constantly send us data every 0.5 seconds. We want to subscribe to that using a callback, and whenever the callback is called, our statistics will be sent to the front end using that callback. Currently, we are just calling this callback with an empty object because we haven't implemented any IPC (Inter-Process Communication) yet, but we can do that later.
The second method we will add is called getStaticData, where our front end will later request static data from the backend, such as CPU name, storage capacity, and RAM. We will also log the word "static" to the console when this method is invoked. Now, if we save everything and ensure that we use getPreloadPath correctly, we should be able to test it out inside our development script by running npm run dev
.
After a brief moment, our app will launch. We can navigate to the view and toggle our developer tools. In the console, we should see window.electron
, and we can already observe that the two methods we defined are present. When we call getStaticData, we should see "static" logged in the console. If we subscribe to statistics and add a console.log(1)
, we will see that the callback was called, and "1" was logged.
Essentially, what we are doing here is telling our main window to run the script before opening the window, thereby preloading it and attaching everything we want in the context bridge to our window under the keyword electron. This allows us to use these specific functionalities.
Of course, one could still make the mistake of importing everything from Electron, which would create a security vulnerability depending on the app. Therefore, it is best not to do that. This is why we are adding these functions that wrap some logic, ensuring we don't give too much power to the UI. We are essentially saying that these two methods can be used by the UI, and we are exposing them to the window for that purpose.
To check this out quickly using our UI, we can head to the window and set something like window.electron.getStaticData()
. Initially, we will encounter type issues, which we will ignore for now using // @ts-ignore
. We will address the typing later because this entire process isn't type-safe at the moment. However, rest assured it won't take long to implement proper types.
After reloading, we should see that our UI automatically logs this value because it can access it on the window. We can observe that "static" was logged two times because we are running in React strict mode, which ensures that our code adheres to best practices.
Mastering Electron means mastering the details—like ensuring your preload scripts are included in the build for seamless communication between processes.
The UI can just use it in whatever way it pleases, so we just check this out real quick using our UI right here. Let's head to the window and just set something like window.electron.getStaticData. First of all, you will see that there are type issues here, which we're just going to ignore for now. So, at this point, we will use TS ignore, and we'll get into that later because all of this process isn't type-safe in itself anyway. Thus, we aren't adding the types right now; we're adding them later on, but don't worry, it won't take long.
Now, let's just reload this, and we should see that our UI now automatically locks this value because it can just access it on the window. Here, we can see that static was locked two times because we're running in React strict mode, which, of course, runs everything twice. However, unfortunately, if we now actually go ahead and build this using npm run dist with whatever operating system you choose, then we're going to see that this won't work in exactly the way we want.
Let's just try it out real quick by building it and waiting for a second. We can now see that there's an error because nothing is rendering. Let's just take a look at our developer tools real quick, and we can see, "okay, cannot read property of undefined reading getStaticData." This is because our preload script didn't actually run, which you can see here: "unable to load preload script." This is the case because we didn't actually tell Electron Builder to include this file. Remember, we intentionally called it CTS so that it isn't bundled in the normal "one file fits all" solution. Basically, we need to import it as a separate file right here in our getPreloadPath method.
Now, we need to tell Electron Builder to actually put that file into that position so that it can be imported at runtime. How do we do that? Well, let's just head into electron-builder.json right here. Here, there is actually another flag or category we can set called extraResources, where we'll define every file that needs to be imported separately from our code. This could be something like an image you want to include or also something like a preload script, for example.
Let's just add the path to our preload script right here, which will, of course, be dis/electron/preload.cjs. Remember, dis/electron is the directory where all our compiled TypeScript and JavaScript code will end up. So, basically, all of these TypeScript files are converted to JavaScript inside of this electron directory, and of course, our preload.cjs will be converted to a preload.cjs right here, which can then be used by Electron Builder to actually give us the desired result.
Now, if we just compile all of this down into an executable once again and wait for a few seconds, we can now see that everything is spinning up perfectly fine again. If we head back to our developer tools real quick, then we can see that static was locked and window.electron contains our two functions right here, which is exactly what we want. So, we now actually got some communication between our main Electron process and our main window using both the development and the production build.
Now, the only thing that's left to understand is, of course, to really go into why these dots are different right here in our production build. If we just take a look at our build using the terminal this time, we just cd into the dis directory. Because I've created a Mac build, we can actually dig really deep into the files. So, let's also do that by just doing ls, and here is a Mac RM 64 directory that we're going to go into. This now contains a w, which is the actual executable you can just click on in your terminal.
So, we just do open ., and you're going to see this thing is our Electron course that we're going to click on to open the app. However, because we're using the Mac build right now, we can actually go deeper by cd into this little file right here, so cd electron-course. As you can see, there's content in here, and now we can just cd into the contents. This then will contain more stuff, for example, the Mac folder that will once again have the application in it, and the resources folder, which will actually contain all of our code and stuff.
Let's just go into resources right now, and we'll just open this using the actual file explorer to have a better way of understanding what's going on here. As you can see, there are multiple files here: there's a desktop icon, there is an app.asar file, and there is also a dis/electron folder somewhere in here. This dis/electron folder now contains our preload.cjs file, and this is basically what we're doing when we're defining extraResources. These resources will land directly inside the resources folder under the same folder structure they are in our project.
Understanding your app's file structure is key to managing resources effectively.
In this discussion, we delve into the structure of our project and how to manage resources effectively. First, we can navigate into the contents directory, which contains various folders and files. For example, the Mec folder houses the application, while the resources folder contains all of our code and related files.
Let's explore the resources folder using the file explorer for a clearer understanding. Within this folder, we observe multiple files, including a desktop icon, an app.asar file, and a disc electron folder. The disc electron folder specifically contains our preload CJs file, which is crucial for defining extra resources. These resources will be placed directly within the resources folder, maintaining the same folder structure as in our project.
When examining the root directory, we notice that we have copied the disc electron folder, but the only content we are focusing on is the preload CJs file. Essentially, everything placed inside the extra resources section will be included in the resources directory of our app. This setup allows us to run our application locally, as it mirrors our current file system.
However, a question arises: where is our code actually running, and why do we need to navigate up one directory? This is where it gets a bit complex. All of our bundled code resides in the app.asar file, which is not a real directory but rather a fake directory that contains all of our code in a bundled format. Consequently, we cannot use file system reads for the preload script to access files within this bundle. To access anything outside of this directory, we simply need to go up one directory, which is facilitated by a path resolver.
In development mode, we access the disc electron folder from the root of our app. In production, we exit the app.asar using two dots and then access the electron.p.CJs file located in the resources directory. Now that we have clarified these points, we can proceed to send data over the IPC event bus, which was the primary reason for setting up the preload script. This will enable us to display information within our UI.
To send our data to the front end, we first need to specify which window should receive the data. This will be our main window, which is of type browser window. We must pass this main window as a reference to the whole resource since we are currently outside its scope. Instead of logging our data to the console, we can now utilize the main window.web contents to send an event.
We will send an event named statistics, with the payload being the large piece of data we are currently logging. Essentially, we are stating that on the statistics event, we will send this data every 0.5 seconds. If the front end is interested, it can listen for this event; if not, that is perfectly acceptable, as we do not expect anyone to listen if the window hasn’t opened yet.
After saving our changes, we should head over to our main file to pass the main window to the poll resource method. Once everything is saved correctly, we can return to our preload script and utilize the callback we defined earlier. Here, we will implement the electron IPC renderer, which is the UI component of the IPC protocol.
We want to add a listener for when the statistics event is received. Upon receiving this event, we will invoke a specific function. To retrieve the data, we will have two parameters: the first is the event, which provides information about who published this event. In our case, this will always be the main process. While we are not particularly interested in the event at this moment, we will later implement checks to ensure that events do not originate from untrusted sources. However, that is a topic for another time.
Mastering IPC in Electron means seamless communication between your UI and backend, making data flow effortless and efficient.
In this section, we will actually use the callback we defined before in a proper manner. Now, we can say electron do IPC renderer, which refers to the UI part of the IPC protocol. Here, we want to add a listener for receiving the statistics event. We will just copy this from over here real quick. Upon receiving the statistics event, we want to call the following function.
To get data, there are actually two parameters. The first one is the event, which can be used to get information about who published this event. In our case, this will always be the main process, so we aren't really interested in the event right now. However, later on, we will do some checks to ensure that events aren't coming from untrusted sources, but that's something for another time. For now, we can just say the event isn't read, which is important. We'll just call it underscore, and then we'll add our data, which are actually the statistics we are currently sending.
What we can do now is actually just go ahead and say callback and call our callback with the statistics, so stats right here. Now, this should already be working. Let's head to our UI, remove this window, and actually add a use effect in here to do the actual subscribing. We are doing this in a use effect because, given an empty dependency array, the use effect will run only on the first render and not on every render. We, of course, don't want to subscribe a million times; we only want to subscribe once.
In this section, we will again need to add TS ignore for all of this because we didn't define the types yet. That will be the next step coming up. Here, we can now just say window.electron and then copy subscribe statistics from over here. We can then say this is our stats and just console log them again. This isn't much different from what we did before, but now we aren't logging them in the electron backend; we are actually logging them in the front end.
Let's try that out real quick by running npm run def, waiting a few seconds, and then we should see that our UI is now popping up. If we head into the developer tools, we can see our usages are already picked up, and everything is working just fine. So, communication from the backend to the front end, in basically a UDP style manner, is now working. We are sending data and not really caring about whether it's received.
Now, let's get into the other direction, where we say, "Okay, I'm the front end; I want to get the static data, please respond to me." To do this, we will head into our preload script once again and say electron IPC renderer do invoke. If we just leave this right here, we can see that invoke resolves with the response from the main process. Basically, while send just sends data and doesn't really expect a response, invoke is there to expect the response.
Here, we can now define an event called get static data. We actually don't need a second parameter because invoke will return the value that the IPC main process returned as a promise. So, just defining what event we want to listen for is completely sufficient. Now, to actually listen to this event, we will need to go back to the main process and say IPC Main. We could do something like on, for example. However, remember that on and send are for more of a UDP type of communication, which sends data without expecting anyone to listen for it.
What we actually need to do is handle an event because the UI expects the backend to respond. Thus, it needs to handle it. Now, we need to handle the same event that's coming in, which is get static data. When this event comes in, we will call another callback. Here, you can get the event, and we will verify this exact event later on, but not quite yet. Without any types, all of this is not as helpful as it might be at a later point in time. Remember, the types come next, so don't worry.
Now, we can basically just go ahead and say we want to return get static data, which we already defined before. Remember, it just returns our total storage, CPU model, and total memory in gigabytes. If we try this out by restarting our UI real quick and calling this into the console, we can see that, given that all of this is async, it would be too much effort to mock it quickly. Instead, we can just run it inside of this console, and everything is fine.
Now, if we run await window.electron.doget static data, we can see that after scrolling up a bit because all of this loads really quickly, we get my total storage of 245 GB, a CPU model of an Apple M1, and my total memory of 8 GB. Thus, we can now request data in more of a synchronous manner.
Type safety in coding isn't just a best practice; it's a game changer for building reliable applications.
Remember, the types come next, so don't worry. Now, we can basically just go ahead and say, "I want to return get static data," which we already defined before. Remember, it just returns our total storage, CPU model, and total memory in gigabytes.
Now, if you just try this out as well by restarting our UI real quick and calling this into the console, everything is fine. Given that all of this is async, it would be too much effort to mock it real quick. We can just run it inside of this console. If we run await window.electron.getStaticData
, we can see that after scrolling up a bit—because all of this loads really quickly—we get my total storage of 245 GB, a CPU model of Apple M1, and my total memory of 8 GB.
So, we can now request data in more of a synchronous fashion. Sure, it's still async, but we are expecting a response, or we can just send data to the front end. I don't really care if anyone actually gets it, which is especially important because we might send data at a point in time where the front end hasn't loaded yet, for example. Expecting a response would, of course, be detrimental in that use case.
These are the two types of communication that we are needing for our app right now. So, let's now actually make them type-safe because all of the any
and T
signs are, of course, not really best practice, as you can imagine. Let's optimize this now.
To start off with our refactoring, we'll first handle the window object, which is basically the type safety between our preload script and our UI. Then, we'll go over to the IPC process. To start off, we first need to define a new file to save our global types in. We're going to call it types.d.ts
, and this file will basically contain all types that we share between our front end and our back end.
Importing the same file from the front end and the back end can get really messy real quick, so we'll just define everything in here. Do be careful to not define too much in here, though, as global types do tend to be kind of an anti-pattern in many cases. So, just be careful here.
To get started, we first define a type called Statistics
, and that type will, of course, have all the fields from our statistics object. To do that, we'll now just head over to our resource manager again and check out what object we are putting in here. As you can see, the object is just this right here. For now, we can say we have CPU usage that's a number, RAM usage that's also a number, and of course, we need to close the object so that all our errors disappear. We also have storage usage, which is also a number.
Now, let's do the same with the static data. Just heading back to the resource manager once again, we will copy over this object and define what it contains. So, our total storage is once again a number, our CPU model is actually a string because it's the name of the CPU, and then our total memory in gigabytes is, of course, also a number.
Now we can just save this, and we need to somehow add it to the window. To do that, we'll just go ahead and say interface Window
. An awesome part about TypeScript right here is that if we define an interface that already exists, like the window, then we basically add stuff to the existing interface. Everything that's already part of the window will stay part of the window, but everything we define in here will be added to it.
Now we can just define electron
in here because, as you might remember, in our preload script, we called our bridge electron
. Our object will, of course, also need to be called electron
, and then we can just add these two methods we're defining here.
So, our window will have a subscribeStatistics
method, which will return void for now. We will later on also add an unsubscribe method as a return type, but for now, we'll just stick with that. Of course, we'll need the type from before again, so this thing takes in a callback that will be called whenever the subscription gets updated. Our callback will, of course, get the current statistics, so the statistics have the type Statistics
right here, and it just returns the type void again.
Then, once again, we also copy over the getStaticData
method right here. getStaticData
will, of course, also be a function, but this function actually expects a return value, which is, of course, the static data. Because we need to await the static data, as you might remember, we need a Promise of static data right here.
Now we have basically already defined our window. If we now just head into our app and try to remove this any
sign, we can see electron is still not defined. Well, why is that? We didn't actually tell TypeScript to use this types.d.ts
file yet. So, let's head into a tsconfig
at the root level, the one for UI, and inside of the compiler options, we'll add...
Type safety is the key to seamless communication in your code—define your types and watch everything fall into place.
In the current statistics, we have the type statistics defined right here, which just returns the type void. Once again, we also copy over the get static data method. This method will, of course, also be a function; however, it actually expects a return value, which is the static data. Since we need to await the static data, as you might remember, we need a promise of static data right here.
Now, we have basically defined our window. If we head into our app and try to remove this T signore, we can see that electron is still not defined. The reason for this is that we didn't actually tell TypeScript to use this types.d.ts file yet. So, let's head into the TS config at the root level, specifically the one for UI. Inside the compiler options, we will add a new field called types. This field is basically just an array of files we want to include that contain Global types. In this case, this will be do/types.d.ts. Remember, a d.ts file is for defining Global types, such as defining what a module looks like for example, which didn't have type definitions, or for our use case, just defining Global types.
Now, if we head back to the app, we can see that electron is now defined, and subscribe statistics is also defined. Consequently, our stats have the type statistics, which is really awesome. Of course, the same thing now applies to our other method, the get static data method. However, this is only one side of the equation; we also want the other side to be type safe as well.
Let's head to the TS config of our electron project. Remember, this is located in Source/electron/tsconfig.json. In here, inside the compiler options, we will do just the same: add Global types. This will again be types, and our types will be do/do/do/types.d.ts because we have two directories underneath our root directory. Just so you know, I made a little mistake right here. This is actually something that VS Code understands perfectly fine, but the TypeScript compiler sometimes has issues with it. Instead of calling this do/types.d.ts, just call it do/sl/do/types, and the d.ts will basically be inferred from TypeScript.
I have already implemented some functions right here, which you will see shortly. To ensure that it's actually working, I'll go ahead into the types.d.ts file. Please ignore all the other stuff right now, and then I will just say that the CPU usage is of type never. This way, we can see that it is still breaking stuff if the CPU usage isn't right. We can observe that type number is not assignable to type never. Defining it like this will still work, but it will also work while transpiring because without this change, npm run transpile electron would actually not work. This is something I didn't realize because I seemingly didn't do a build.
If we just rename this back to d.ts, then we will see that npm run transpile electron will throw an exception. However, calling it types will not cause an exception. Of course, please don't forget to do the same thing inside the UI, where we also have do/types.d.ts, which just needs to be /types. Now, if we save this and head to our preload script, we can say that this object we are passing to our window will need to satisfy window, and from the window, we want the electron object.
Now, everything is working just fine, and we can even remove this any bit right here or even the complete type right here. Our callback will still be statistics statistics because this is implied from the satisfies keyword down here. If you don't know what satisfies does, by the way, it basically just tells TypeScript that we expect this object to be of this type. Instead of using as, where you basically just overwrite the type, you tell TypeScript this object needs to have this type, and if it doesn't, then please throw an error.
You could also define a variable that has the type window electron and then assign the value to that. However, since we aren't really using any variables right here, we are just passing this as a parameter, so satisfies is the right way to do things. Now, we've basically achieved type-safe communication between our preload script and the UI, even though this invoke method, for example, isn't type-safe yet.
If I just went over to the invoke method real quick, in our main process, we've got this get static data right here. For now, we could still just return an empty object, and nothing would complain, even though the code is, of course, false now. That will be the next step. However, what we have achieved at least is making all of this between the preload script and the UI type-safe. If I now went ahead and just commented this out and returned...
Type safety is key in coding; it prevents errors and confusion by ensuring data types match perfectly across your application.
In this discussion, we are focusing on improving type safety in our Electron application, specifically in the communication between the preload script and the UI. We start by mentioning that we are using window electron and assigning values, but since we aren't utilizing any variables at this point, we are merely passing parameters. This approach satisfies the requirements for type safety, and we have effectively achieved a type-safe communication between our preload script and the UI.
However, we note that the invoke method is not type-safe yet. For instance, in our main process, we have the get static data function, which, for now, could simply return an empty object without causing any complaints, even though the code is technically incorrect. This indicates that while we have made progress in type safety between the preload script and the UI, there is still work to be done. If we were to comment out the current implementation and return something like null, the system would complain because null is not assignable to a promise of static data. Thus, we have already improved our type safety significantly.
Now, our next step is to ensure that the IPC (Inter-Process Communication) communication is also type-safe. To achieve this, we will utilize more global types, ensuring everything operates as optimally as possible and preventing common mistakes, such as confusing strings for numbers. Before we finalize our IPC communication to be type-safe, let's examine its current state and the issues we need to address.
Currently, we have our Electron site and our preload UI, with IPC acting as the intermediary. At this moment, IPC uses any for any payloads we send over it, regardless of the event being utilized. To resolve this issue, we will implement adapters. These adapters will facilitate the sending and receiving of data between the two sides, ensuring that we maintain type safety throughout the process.
The plan is to create adapters for sending data from one side to the other and for receiving data on the opposite side. This will also apply to sending data from the UI to Electron. By using these adapters, we will encapsulate the IPC process within a type-safe shell, effectively eliminating any any types from escaping into our application. We will establish a mapping of types, where each event corresponds to a specific type. For example, event one will have type one, and event two will have type two.
We will define a function, which we will call send, that will utilize IPC renderer to send an event name and a payload. By employing generic types in this send function, we can ensure that for a given event name, we always have the correct payload type, as the event name will serve as a key in our mapping. If this concept is not entirely clear yet, there is no need to worry; we will implement it shortly, and it should become clearer through practical application.
To kick off our implementation, we will first navigate to our types.d.ts file, which contains our global type definitions. Here, we will define our event payload mapping. The keys will represent our event names, such as statistics and get static data, while the values will correspond to the types sent as payloads. For instance, the type for statistics will be our previously defined statistics type, and the same applies to static data.
What we aim to achieve with this mapping is the automatic generation of functions. For example, we will create a function called handle get static data, which will act as a handler for the get static data event. This function will retrieve static data from our resource loader. When we interact with our get static data method, we will expect a callback that does not take any parameters and returns static data. However, we want to avoid defining such functions repeatedly, even though this approach is already more type-safe than using any.
In conclusion, by implementing these changes, we will significantly enhance the type safety of our Electron application, ensuring that communication between the preload script and the UI is robust and error-free.
Streamlining your code can transform complexity into clarity, making it easier to manage and less prone to errors.
The same applies for the static data, and what you can now imagine is that we're going to use this mapping to essentially automatically generate functions like this. We have a function right here that says handle get static data, which is going to be a handler for a get static data function that we already have. Remember, it's basically the IPC main.handle for a get static data event, which will then retrieve the static data from our resource loader.
Basically, what we're going to say here is that whenever we want to interact with our get static data method, we are essentially expecting a callback that looks like this: we don't get any parameters, and we return static data. However, we don't want to define a function like this every time. Even though this wrapper is already type safer than just using IPC main because it checks that everything is correct, if I were to return null here, we would get an error because null is not of type static data. Therefore, we want a generalized solution that works every time and acknowledges our type mapping.
Now, let's just get rid of this and also undo these changes by reverting to our normal situation with our type mapping intact. We can actually get into implementing our generalized solution. Let's head into a Ule file and define a function. The IPC handle will basically be a wrapper around IPC main.handle. IPC main.handle takes an event name, which we're just going to call key, and it will take in a callback. Right now, this callback returns something we don't know what it is yet, so we aren't really interested in anything about it right now.
Let's actually pass through these little things. The key will, of course, be a string because our event name will always be some kind of string, and we'll use the mapping shortly after we've implemented a basic wrapper. We also want the handler, so let's just call it Handler real quick. Our handler will need to return the event type mapping, so we aren't using this yet, so let's just call it any for now. We also need to call the handler, so we can basically just say we want to return the return value of my handler.
Now, how do I actually make this type safe? First of all, we need to make this generic because we won't know what type something will have until we have a parameter. We will only know the return type of this function once we know what key we have. Therefore, we will always need generics for something like that. Let's create a generic called key right now. Key is basically an any type, which causes a complaint because key is not assignable to type string; it could be anything, including a number.
To solve this, we can say that key extends string, and now we've basically done exactly the same thing as before because the key is now just any type of string. However, what we actually want to do is limit the key to be one of the strings in the keys of our event payload mapping. So, either statistics or get static data because those are the events we've defined. We don't want the user to use any events that they didn't define before, nor do we want any of us developers to do that since the user doesn't define events anyway.
Now, how can we actually solve this? Instead of saying string, we can say we want the key of my event payload mapping. What key of event payload mapping essentially does is limit my options of what types something can have to the left side of this object, which includes statistics or get static data. This is how we've limited my key. If I now wanted to call IPC handle with something like get static data or statistics, that would work. However, if I passed X and a callback, that wouldn't work because X is not a key of event payload mapping.
We've already limited it quite a bit, but this is still any. What we can now do is say that the return type of our handler will be event payload mapping, and from that, we want the key. Essentially, if I pass key from the event payload mapping, for example, if I pass statistics, then please get me the return value of event payload mapping statistics, which is, of course, the statistics object.
Now, if we try this again, for example, IPC handle get static data, it now expects me to pass in static data right here. An empty object is not a full static data object, so it now complains. Basically, what we've done now is made IPC handle completely type safe because it is forced to always use this mapping.
Type safety is the key to seamless communication between your backend and frontend; it ensures that every piece of data is exactly what you expect, eliminating errors before they happen.
The current implementation is not a key of event payload mapping, so we have already limited it quite a bit. However, this is still an issue. What we can now do is basically say that the return type of our Handler will be event payload mapping, and from that, we want the key. Essentially, if I pass the key from the event payload mapping, for example, if I pass statistics, then please get me the return value of event payload mapping statistics, which is, of course, the statistics object.
Now, if we try this again with IPC handle get static data, for example, it expects me to pass in static data right here. An empty object is not a full static data object, so it now complains. Essentially, what we've done now is made IPC handle completely type-safe because it is forced to always use this mapping as long as we always use this wrapper and do not call IPC main.handle directly. We can ensure that the Handler will always do the correct thing in the background.
Of course, if we now do the same thing for the invoke function in the front end, which we are going to do shortly, then both sides are type-safe. We can essentially ignore the fact that IPC main.handle itself isn't type-safe because, as long as we always use our wrappers, everything will be completely type-safe, which is exactly what we want.
Next, let's just go into exporting this real quick. We head into main.ts from Electron, where we currently have IPC main.handle. Here, we can essentially just go ahead and say IPC handle Quick Fix import. Now, this thing is completely type-safe because it returns the return value of get static data. If we take a look, get static data returns static data, so the type safety is now complete. If we do a little type check right here by returning null, for example, we can see that null is not assignable to static data, confirming that this part of our project is actually type-safe now.
Now, let's do it for all the other parts as well. Given that we are already on the Electron side and not the front end side yet, let's also implement the other function we will need for our back end, which is going to be sending data to the front end using the send function. Remember, when we want to send something to the browser, we basically use the web contents or the browser window object to send the data.
I've just copied this function, and as you can see, the key is the same. The key is just the generic parameter, and we need to reference the web contents that we want to send something to, which is basically which browser window we actually want to send this data towards. The payload is again event payload mapping key, so if I wanted to use statistics as a key, then the payload would also need to be of the type statistics to ensure everything works just fine.
To make sure that our naming is coherent, we will rename this to IPC main handle and this to IPC web contents send, just so we always know what's happening under the hood. This function wraps IPC main.handle, and this one wraps web contents.send. Now, let's head over to our resource manager, where we are currently pulling our data. If you remember, this runs main window.web contents.send, and we can replace this with IPC web contents send. The second parameter will, of course, need to be main window.web contents, and we also need to import it from util.
However, we are seeing some issues right here. The error states that type unknown is not assignable to number. The problem isn't actually that IPC web contents send is not type-safe; the issue is that get CPU usage, a wrapper we wrote around OS u.CPU usage, doesn't actually know what type it returns. Right now, it returns a promise unknown, which is not what we want. We actually want a promise number.
What we can essentially do right here is force the return type because we know what it is. Let's just say this is a promise number, and now all the types are safe. We have basically just wrapped this little library, so this shouldn't really be an issue. If it is, then we'll find out shortly.
In summary, we have now made both sending data and handling events on the Electron side type-safe. The next step is, of course, our preload script, which is basically our UI, to also make the other side type-safe. Then, the full process between the back end and front end will be completely type-safe, allowing us to add new events simply by adding them to the event payload mapping. Now, let's get into actually using this on our front end. Remember, all of our front end...
Creating a type-safe connection between your front end and back end not only prevents errors but also streamlines your development process.
To ensure that our types are safe, we start by forcing the return type. Since we know what it is, we can confidently say this is a promised number. Now, all the types are safe, and we have essentially wrapped this little library, which shouldn't pose any issues. If there are any problems, we will find out shortly.
At this point, we have made both sending data and handling events on the Electron side type-safe. The next step is, of course, our preload script, which serves as our UI. This will also ensure that the other side is type-safe, completing the process between the backend and frontend. Consequently, we won't have to worry about types anymore, and we can simply add new events by updating the event payload mapping.
Now, let's delve into actually using this functionality on our frontend. Remember, all of our frontend Electron operations occur inside the preload script because we want to expose as little of Electron as possible to the frontend. In our preload script, we can now define more IPC (Inter-Process Communication) reer functions. These functions need to be defined within the preload script rather than inside the utils file. Unfortunately, due to how Electron Builder handles this, we cannot import the util file inside our preload script. It's important to note that a .cts
file will not be able to import a .ts
file and vice versa.
Thus, we will define these functions here. We can create an IPC invoke function that takes in a generic key and returns a promise with the payload mapping. Since invoke is asynchronous, it will need to return the promise of the mapping instead of just the normal type. Each time we use it, we need to await the backend's response, which is akin to a fetch request—always asynchronous. On the other side, we have IPC on, which is used for polling or static data. Here, we also include the key as a generic and define a callback that executes every time the backend sends data. This callback will send a payload that must adhere to the event payload mapping with the key.
For statistics, this will correspond to our statistics object. We are essentially wrapping Electron's IPC renderer with the key while discarding the event for now. The payload, which is normally of type any, will be passed to a type-safe callback. Consequently, we can now use these functions: IPC on will become IPC on with the statistics object, and the event will be removed to enhance type safety. Additionally, get static data will also be IPC invoke get static data. Now, everything is fully type-safe.
If I change anything in the type definition, for example, if I modify the return type to null in the types.ts file, we will see that the preload script becomes corrupted. This happens because promise static data is not assignable to promise null. In our window, we define what get static data should return as a type. Thus, the types.ts file acts as a contract between our frontend and backend, ensuring that all types are correct.
To rectify this, we can revert it to static data, and now all our contracts work seamlessly. As long as we avoid using IPC directly and only utilize the predefined adapters, everything should remain fully type-safe from one side to the other. Moreover, since we have only written reer functions, everything should continue to function properly, which is fantastic.
Now, let's finally address the validation of events to prevent the sending of any malicious data that might have incorrect types, which could be even more problematic. To validate something, we need to consider what data we actually have. As mentioned earlier, we have this event, which serves as a parameter to any handler or on function we are executing, whether on the backend or frontend. However, since we primarily need to validate on the backend, we will focus our efforts there.
The event contains something called event.senderFrame, from which we can extract information about the current location of the user in their browser. This allows us to verify that our code sent the event. For instance, in a production environment, our current URL is typically structured as follows: our dis
folder, SL
, and index.html
. We now need to verify that this is indeed our current location.
Validation is key to securing your app; always ensure events come from trusted sources to prevent malicious attacks.
In our current implementation, we are focusing on validating events primarily on the back end. We have an event that includes something called event.senderFrame, which allows us to gather information about the current location of a user in their browser. This is crucial for verifying that our code is indeed sending the event from the correct URL, specifically the one that is currently opened in the Electron process.
As you may recall, our current URL in a production environment is structured as follows: this/react/folder/index.html. To ensure that we are validating the correct location from which we are accessing these files, we will generalize our approach. We will return to a previously created path resolver and define an export function getUIPath. In this function, we will return path.join(app.getAppPath(), 'dis', 'react', 'index.html'). This allows us to import this function and utilize it to verify our current UI path.
However, the situation becomes more complex because we are using a different URL in a development environment compared to our built environment. In development, we are interacting with our V server rather than local files. Therefore, we need to differentiate between these environments to ensure that our validation works correctly in both cases. To simplify this process, we will reuse some previously created code and import the pathToFileURL function from the URL module.
The validation process begins by checking if we are in the development environment. If we are, we will verify that event.senderFrame.url is under the domain localhost:5123, which corresponds to the port defined in our V configuration. If this condition is met, we will wrap the frame inside a URL object and extract the host attribute, allowing us to obtain just localhost:5123 without any additional path or HTTP prefix. This is sufficient for our development testing.
Next, if we are not in the development environment—meaning we are in the production build—we need to check if event.senderFrame.url does not match the pathToFileURL(getUIPath()). This means we are comparing our UI path, which is structured as path.join(app.getAppPath(), 'dis', 'react', 'index.html'), to ensure it is represented as a file URL on the system (formatted as file://...). We will convert this to a string for comparison with webFrame.mainWorld.url.
If these two URLs do not match, it indicates that the request originated from a source other than our UI file, specifically index.html. In this case, we need to throw a new error, as this suggests a malicious event is attempting to enter our system from an unauthorized source.
It is important to note that this validation method is not foolproof. If your Electron app has multiple files or uses a client-side router that renders different paths, you will need to implement more complex checks. For instance, you might check if the URL starts with a specific path and ignore the rest. Validation can become intricate over time, but this approach provides a foundational method for validating events and preventing unauthorized access to your system.
In conclusion, while this is a basic starting point for event validation, it is essential to build upon it to enhance security and ensure that your application remains protected against potential threats.
Validation is key to securing your app; start simple and build complexity as needed.
To begin with, you will need to do something like checking if the URL starts with this and then just ignore the rest of the path or perform more complex checks right here. This validated RAM frame method can become really complicated over time because, as we know, validation is always complicated.
This is just a really simple way of validating by basically going ahead and saying, "Okay, is my frame URL something other than my index.html file that gets generated whenever I build my reactor?" If so, please throw an error and don't let the event through. This is not a foolproof solution, obviously, but it is a starting point for you to build upon so that you know how to validate events and how to prevent someone malicious from entering inside of your system, which you probably don't want.
Now, to actually use this, you can simply say, "I want to validate my event frame" by going in here and saying, "Before I actually handle this event, I can just say validate event frame Event Center frame." Because we've already generalized the IPC main handle function, this is actually everything we need to do. Now, whenever we run IPC main handle, the event will already be validated. This is also why we're doing this now and not a few steps before, because a few steps before we didn't actually generalize this, so it would have been more stuff we needed to copy over. Now it's already generalized, and every IPC main handle function will always validate frames.
Next, let's actually test this out. For debugging purposes, I will just console log the frame URL so you can understand what is happening right here. We will open up the app, go into developer tools, and because we didn't actually call this in any part of our app yet, we are just going to go window.electron.
and then we're going to get the static data. As you can see, the URL is currently http://localhost:5123. If we scroll up to where I did my call, we can see it's a pending promise because, again, every handle event is async. Now, let's just go ahead, scroll all the way down again, and await it just so we can make sure that we actually get the data that we want.
We did actually make a little mistake because we, of course, need to return the value from our handler. Because I added these parentheses inside of a callback, we don't automatically return anymore. By just adding this return handler function real quick, we should now see that if I open it up once again and target developer tools, we should now see our static data right here.
Now, let's also test that our validation is still working by changing this to 5125. We are now checking that the URL is not the one that we actually want, and we should see an error pop up if we now try to actually get this data. We can see error invoking remote method gets the data error malicious event. So, we are basically now limiting the amount of access we've got to our Electron backend, which is always nice for security purposes.
Next, let's also test out the production build. As you can see, the production build is also still working just fine because, as I mentioned, the URL is of course correct here as well. So, our event validation on a basic level is now working, and you can now extend it to whatever level you want in case your app becomes more complicated than mine currently is.
Now, let's get to the final eventing part for now, which is going to be unsubscribing from events to prevent them from piling up on the front end. Luckily, unsubscribing is really simple to implement, but why do we actually need it? Well, if you've ever done something inside of a use effect, like for example a set interval or something, then you might have realized that if you don't stop this interval, every rerender for every change to the dependency array will create a new interval.
In our case, this isn't really an issue because our dependency array is empty, right? But let's say that we are calling this inside of a component that can get unmounted. When the component doesn't render anymore, we, of course, also don't want to do any state updates because that might cause null pointers or just unexpected behavior. When you update the state of a component that doesn't exist anymore, that's really not something we are intending to do.
So, basically, what we can do here is say when this use effect stops—when this component reruns or unmounts—we want to unsubscribe from this statistics thing right here. For that, we will need to return some kind of a function. A common pattern is to do something like const unsub = window.electron.subscribeStatistics
, and right now, unsub has a type of void, so we can't really use it. Let's fix it up by first defining a type for that.
Always clean up after your components to avoid memory leaks and unexpected behavior.
In this discussion, we focus on a component that can get unmounted. When the component no longer renders, we certainly don't want to perform any state updates, as this could lead to null pointers or unexpected behavior. Updating the state of a component that no longer exists is not our intention.
To address this, we can implement a solution within the useEffect
hook. Specifically, when this effect stops—either when the component re-renders or unmounts—we want to unsubscribe from the statistics subscription. To achieve this, we will need to return a function. A common pattern for this is to define a constant, such as const unsub = window.electron.subscribeStatistics
. However, at this point, unsub
has a type of void, which means we cannot utilize it effectively.
To resolve this, we will first define a type for our unsubscribe function. The type, which we will call unsubscribeFunction
, is quite simple: it is a function that does not take any parameters and does not return anything—essentially, it serves as a side effect function. Our subscribed statistics function will need to return this unsubscribe function.
As we proceed, we notice that our preload script throws an error because we are not returning anything. Therefore, we will return the return value from IPC.on
, which also does not return anything. Fortunately, Electron's ipcRenderer
has another version called electron.ipcRenderer.off
, which accepts the same parameters. If the key and the reference to the function are the same, it will unsubscribe from the previous subscription. However, we cannot implement this right now because we are creating two separate functions.
To rectify this, we need to define the function at the top. Our callback will be of the type electron.ipcRenderer.Event
, and the payload will have an any
type since everything inside our adapters is typed with any
. Consequently, we can state that electron.ipcRenderer.on
will take this callback, and off
will also take in this callback. However, we do not want to subscribe and immediately unsubscribe, so we will transform this into a function that can be called to unsubscribe from our Electron process. We will return this function from our IPC.on
function.
Now, what is happening is that when someone calls IPC.on
, it will return the unsubscribe function. An alternative way to write this is to return the result of IPC.on
, which is our unsubscribe function. If we return a function from the useEffect
, it will execute either when the dependencies change or when the component unmounts. This means that when the component is no longer rendered, we are effectively cleaning up after ourselves every time we no longer need the data or the event listener. This practice helps us avoid unintended consequences from multiple event listeners and reduces memory usage, minimizing the risk of memory leaks.
With this setup in place, we can finally move on to developing our UI. As you can see, I have already prepared some elements because we will be using the library Recharts, which you can install by running npm install recharts
. I will not delve deeply into setting up the graph; instead, I will explain its functionality. Once we understand how Recharts works and how we will utilize it, we can proceed to populate this chart with our actual data, presenting it in a visually appealing manner.
To illustrate, I have set up a base chart where we are passing a few data points: 25, 30, and 100. This results in a slight slope initially, which then becomes exponential as it approaches the value of 100. Let's take a quick look at what I have established here to clarify the code's purpose before we move forward.
We have an area chart, which is essentially a chart with a line and a filled-in area beneath it, designed for optimal aesthetics. We pass our data into this chart, and this setup will serve as the foundation for our further developments.
Transforming raw data into stunning visuals can elevate your user experience from basic to breathtaking.
In this section, we will focus on filling this chart with our actual data to then display the data in the UI in a really nice looking way. As you can see, I have done something really simple right here just so you can see what I'm explaining. We've got this base chart that I set up, and we're just basically passing a few data points in there: 25, 30, and 100. You can see that it has a really slight slope in the beginning, and then it goes basically exponential when going to the value 100.
Let's take a quick look at what I've set up here so you can understand what the code does. First of all, we've got an area chart right here. An area chart is basically a chart with a line that has a filled-in area underneath it, which makes it look as nice as possible. We pass our data in there, which is basically just an array of objects that have a key called value. This value is defined right here, so if we were to rename this to something else, our data would also need to have another key. However, since I think value is a really nice key, we're going to use that.
Next, we have the Cartesian grid right here. The Cartesian grid is basically this little grid that you can see behind the chart. For example, if I went ahead and said I want this in FFF, then you can see that it now becomes a lot different and a lot lighter. However, I think 3 through 3 looks really nice. Using this feature, we can control what the grid looks like; it's just for a bit of a visual kick. We don't really need it, but I think it's quite awesome.
Then, we define what our actual area and line look like. In this case, we want the fill of the area to be slightly transparent so you can see the line behind it. It has a transparency of 0.3. If we were to set this to 1, for example, then we would see that you can't see anything behind it, but I think 0.3 looks really nice. We then define the color of the background, the color of the line, how thick the line should be, and what type of line it should be. Should it be a straight line or should it be smoothed out a bit? All that stuff is defined.
We again define the data key that I've talked about before, calling it value and returning animations. I think the animations are a bit over the top with this library, and for our use case, we just really don't need them anyway because we're trying to recreate the Windows task manager, which also isn't really animated.
Next, we define our x-axis, which we're defining with a height of zero because we don't want it to be visible. We also define a stroke to be transparent so we don't see any line at the bottom. If you didn't set the height, then even though we set everything as transparent, it would still take up space, which we also don't want. So, we are basically just setting the height of the x-axis to zero and the width of the y-axis. We also specify that it should start off at zero and go to a maximum of 100, which is the case because we're displaying percentages. We can't have a utilization less than zero, and we want our maximum utilization to be 100%. You can see that the line ends at the top because the highest value I passed through is 100, and the lowest value that we can display is, of course, zero.
Now, we've got this area chart that contains all of this data and a responsive container, which is basically just used so that our chart knows what size it should have. In this case, we are just setting its width and height to 100% so that we can externally control the height using a div. This chart will only work when placed inside a div. If we just remove this, it will behave quite weirdly because it doesn't know what size it should have. Therefore, it always needs to be inside of a div with a fixed height. Making this responsive is really hard, honestly, so you will need to tinker a bit if you want to use this library in a responsive manner. However, in our case, that's not really the case.
When we're actually using this chart, we can just set some certain values for the height of the bounding box, which is basically the div that this chart will be inside of. We can then use it in any way or form that we like. Now that we've gone over how this grid will work, let's actually put our data from the Electron backend into the grid. This way, we can finally get rid of this basic UI that we've always been looking at and actually get our updated values from the Electron backend inside of this awesome UI. First of all, of course, we will remove this little bit I've created right here so that our app basically has no differences now whatsoever. It's basically just the same as your app should be.
Transform your data flow by creating a custom hook that keeps your statistics in check, ensuring you always display the latest insights without clutter.
In our project, we want to use this library in a responsive manner; however, in our case, that's not really the situation. When we're actually using this chart, we can just set certain values for the height of the bounding box, which is basically the area that this chart will be contained within. This allows us to use it in any way or form that we like.
Now that we've gone over how this grid will work, let's actually put our data from the Electron backend into the grid. This will enable us to finally get rid of the basic UI that we've always been looking at and incorporate our updated values from the Electron backend into this awesome UI. First of all, we need to remove the little bit I've created right here, so that our app has no differences whatsoever. It will essentially be the same as your app should be, with the only differences being the base chart that we've just discussed and the installation of Recharts using npm install recharts
.
Let's proceed by implementing our Electron data into this grid. To do that, we first need to get our data inside of our React state. Currently, we're using a useEffect
to subscribe and unsubscribe from our data, but we're only console logging it and not storing it anywhere. To address this, we'll create a new file called useStatistics.ts, where we will define a custom hook that will handle this for us. We will, of course, export it so that we can use it elsewhere.
For now, we can copy over the useEffect
we already have and import that quickly. Instead of console logging, we want to define some state to house the data using useState
. This state will actually be an array of statistics because we don't just want to display the statistics at one point in time; we want a range of data points that we will define as a parameter to this hook. Essentially, we will specify a data point count as a number, indicating the maximum amount of data points to be displayed in our grid or whatever use case we have.
We will take care of preserving this many data points. If we go over or under, we will either need to pad the end so that the data points match up, or if we exceed the limit, we will need to remove the earlier bits to ensure that the newest bits remain at a specified position, for example, if we only want 10 data points. We can define value
and setValue
here because, as this is a custom hook, we don't need an elaborate name. We will return the value
, which is now a statistics array that we can also define right here to ensure everything is correct inside of this hook.
Instead of console logging, we need to set the value, and we will also need the previous value to update our current value. The simplest solution would be to say we want all of the previous values plus the new stats. However, we haven't utilized the data point count yet. For instance, if we specify that we only want 10 data points and we already have 10, we would inadvertently add 11, 12, and 13 at the end without clearing up after ourselves.
To solve this, we will say const newData = this
, and then return newData
from our set function. This allows us to access the previous data inside of this callback, and the return value will be the new data being set. In this section, we can check if newData.length
is greater than our data point count. If it is, we will call newData.shift()
, which removes the first element of the array. Thus, as soon as we exceed the data point count, we will remove the first item, and when the next item comes in, we will remove the second one, and so on. This way, we will always maintain the specified number of items.
This is essentially the hook we will use. Now, we can get rid of the previous setup and try it out by saying const statistics = useStatistics()
, and we can add a data point count, specifying that we only want 10 values. Let's console log this and run npm run dev
real quick. We should see that we're now logging this to our console.
By opening the developer tools, we can observe that the array becomes longer every time the polling takes place. Once we reach 10, we stop adding new items because we are continuously replacing the first item with the second, and so on. The last item will always be our new one. Now we have an array that effectively portrays an amount of time.
Transform your data into visual insights that tell a story, and watch your numbers come to life.
The second one, and so on and so on, so that at most we'll always have a data point count of items. This is basically just the hook we're going to use for us. Now, we can get rid of this real quick and try it out by saying const statistics = useStatistics()
. We only want 10 values, so I'll just say we want a data point count of 10. We'll just console log this, so let's run npm run dev
real quick, and we should see that we're now logging this to our console.
Let's open the developer tools, and here we can see that the array becomes longer every time the polling takes place. At 10, we're stopping because we're basically always replacing the first item with the second, and so on. The last item will be our new one. Now we've got an array that basically portrays an amount of time that has passed, which is going to be really awesome for our visualization in our graph.
Next, let's actually get this into the graph by defining a new function for that in a new component. We'll create a new file called chart.tsx
, which is, of course, going to build upon our base chart. We need some props, but those will come shortly. Here, we're just going to return a base chart, which only has one property called data.
What we need to do here is manipulate our data so that it fits. If we take another look at our statistics, we can see this is a variety of different numbers. Let's say we want a chart that only visualizes our CPU usage. What we've got is going to be an array of numbers because we have an array of statistics for each statistic, and we're going to get the number of CPU usage. This way, we'll have an array of numbers, each representing the CPU usage.
Now, let's get into our app and implement it real quick. We can say const CPUUsages = useMemo()
to optimize performance. This useMemo
will go ahead and say statistics.map()
, and for each stat, we want stat.cpuUsage
. Now, let's add the dependency array so that we want this to refresh whenever the statistics change. Now we've got a number array, so let's try adding this chart already, including a div of course, because as you saw before, we actually need this div with some kind of height, which I'm just going to do using inline styles real quick. We'll fix it shortly.
Now, we'll just add this little chart right here. We need our props, so we export the type ChartProps
, and we need data, which is just going to be a number array. Let's now put props.data
in here and say our props are of type ChartProps
.
However, we encounter an issue: type number is not assignable to type value number undefined. This is something we're going to need to work with, but this conversion will take place inside of this chart. So, let's first go ahead and say data = CPUUsages
. The next step is, of course, to convert our data. We need some way to manipulate this, and the easiest way is to go ahead and memorize this as well.
We can say const preparedData = useMemo()
because whenever we want to manipulate state into something else, we basically use useMemo
. In here, we can now say props.data.map()
and we want each data point to be converted into an object that looks like this: value: point
. Now we need the dependency array in here, which is props.data
, so whenever our props.data
change, we want to update this.
Now we can pass the preparedData
into a base chart, and we should see that it's now working just fine. Let's run npm run dev
once more, and we should see our chart popping up. As you can see, we've got this data right here, and it visualizes something, but it's really tiny. That's because our data is currently in a range between zero and one, while our chart is in a range between 0 and 100.
The easiest way to solve this right now is to just convert this into percentages by going point * 100
. Then, we'll open up our Electron app real quick by hitting npm run ev
again, and now we should see our data points actually popping up. We can see there's a slight wiggle in there because my CPU usage is changing as I'm running and recording this app.
That's actually working already, which is really nice. However, as you might have noticed, if we just restart, the grid looks kind of awful in the beginning because we add one data point, then two, then three, and it becomes wider, which isn't really what we want. So, let's go ahead and also add max data points as another parameter to the chart.
Maximize your data visualization by ensuring your charts always display the right number of points for a clean, flowing graph.
To solve the problem of displaying data points between 0 and 100, the easiest way is to convert the values into percentages by going point 100. After that, we can quickly open our Electron app by hitting npm run ev* again. Now, we should see our data points actually appearing on the screen. There is a slight wiggle in the graph due to my CPU usage changing while I am recording this app, but overall, it is working well.
However, if we restart the grid, it looks somewhat chaotic at the beginning. We add one data point, then two, then three, and the graph becomes wider, which is not ideal. To address this, we should add Max data points as another parameter to the chart. We can define this by saying const points = this, and we need to return the points again.
Next, we can implement a condition: if the length of the points is less than props.maxDataPoints, we can append more data points at the end. A simpler solution is to spread our points and then spread an array created using Array.from, specifying the length as props.maxDataPoints - points.length. This essentially creates an array filled with undefined values, which fills in the gap if we want 10 points but only have five.
However, we will encounter a type error because value number or undefined is not assignable to type unknown. To resolve this, we can map all the new items and set the value for each item to undefined. This way, we have created the missing items for our array to reach the desired length of Max data points.
We also need to include props.maxDataPoints in our dependency array since it might change, even though it probably shouldn't. Now, we can go into our chart and set Max data points to 10. After restarting our Electron app, the graph should look much better. The first point appears, and the other points flow to the right, creating a smooth graph, which is exactly what we want.
Changing the data type is quite simple as well. For instance, if we want to display our RAM usage instead, we can replace the relevant code with RAM usage, rename some variables, and see our RAM usage displayed. Currently, it shows around 100% because I have a Mac with 8 GB of RAM, and I am running multiple applications, including Electron and VS Code, which means I am frequently using the swap file.
Now, we are effectively utilizing our Electron data within our front end, which is quite exciting. This setup is also reusable; if we wanted another graph elsewhere, we could use the useStatistics hook again to retrieve the data, ensuring everything functions properly.
While this is a significant achievement, there is something to note when debugging this process. We still have some unnecessary console logging, which we should remove. If you check the console, you might notice an Electron security warning regarding an insecure content security policy. This is the next issue we need to address, as we want to avoid any security vulnerabilities in our app.
The security policy is an interesting aspect that we can explore to enhance our app's security against potential threats. Although our type of app may not seem particularly vulnerable, developers of larger applications, like Discord, must invest considerable effort into security. Therefore, we will look into how to make our application as secure as possible. Trust me, an insecure content security policy can pose significant risks for many applications, especially if there is any access vulnerability. Let's now focus on resolving this issue effectively.
Strengthen your app's security by implementing a solid content security policy; it’s your first line of defense against data injection threats.
In this section, we will focus on enhancing the security of our app, as we don't want any security issues. The security policy is quite interesting and can help make our app more secure against foreign entities attempting to inject data. While this may not be a significant concern for our type of app, developers of larger platforms, such as Discord, must invest considerable effort into security. Nevertheless, we will explore ways to ensure that our app is as secure as possible.
An insecure content security policy can pose a significant risk for many applications, especially when there are access vulnerabilities. Therefore, it is crucial to address this issue effectively. To understand the importance of a content security policy, we can refer to the Electron security section, which emphasizes that content security policies should be enabled for any site loaded within Electron. This policy restricts the types of data that can be loaded by the app. For instance, if our index HTML file limits Electron to making requests only to specific websites, any cross-site scripting vulnerabilities will be contained by the restrictions on data transmission.
To illustrate this concept, consider a scenario where a malicious actor gains access to another user's UI and attempts to send user data to their server. If we prevent the UI from communicating with that server or loading scripts from it, we significantly enhance our security.
Let's examine two examples of content security policies. The first example, labeled as "bad," states, "my content security policy is everybody can do anything." In contrast, the second example, labeled as "good," specifies, "my script source is self and https://apis.example.com." This means that scripts can only be loaded from the current domain (in our case, index.html or localhost:5123) and the specified API directory, while disallowing scripts from external sources like Google.
To implement this in an HTML file, we can create a meta tag with an HTTP equivalent. This meta tag will set the Content Security Policy. For instance, we can define a default source that only allows content from ourselves, including images, CSS, and scripts. We can also permit inline styles, which is often necessary when working with React, even though it is not enabled by default due to security concerns.
Furthermore, if we want to include external scripts, such as Google Analytics, we can specify that in our policy. However, for debugging purposes, we can set the HTTP equivalent to Content Security Policy Report Only. This option is particularly useful if we have already created our app and are now applying the header, as it allows us to see what restrictions would have taken effect without actually prohibiting any scripts. This way, we can identify any potential issues without disrupting the functionality of our app.
To implement this, we can take the meta tag example and add it to our app by editing the index.html file. Inside the head section, we can insert this meta tag, removing any unnecessary references, such as analytics.google.com, if we are not using Google Analytics. After making these adjustments, our app should continue to function correctly, and the previous warnings should no longer appear, providing a significant benefit in terms of security. However, we may still encounter other errors that need to be addressed.
Simplifying your content security policy can enhance app security while keeping functionality intact.
To begin with, we need to understand the implications of enabling certain scripts. Scripts did load that should have not been able to be loaded. For debugging purposes, you can simply add a report and look in the console. If anything is locked by this policy, then you can know that your policy is too restrictive and that you need to change something about it.
In our case, let's take this little metatag and actually add it to our app by heading inside the index.html. Of course, inside the head, we can just add this metatag. We can remove analytics.google.com because we don't add any Google Analytics here. Now, our app should still be working just fine, and this warning should now not load anymore, which is already quite the benefit.
You may still see some other errors because Recharge didn't really do all the improvements we need for newer React versions that are coming up, but I'm sure at some point they will. These errors are only a temporary issue. Now, let's try to actually break this by making a policy that is more restrictive. For example, we can remove the unsafe-inline bit. This means we now only allow CSS files that don't do any inline styling.
We can see that we definitely need this inline styling because this basically just broke everything; React uses inline styling for some of its components. So, we actually do need to enable this. By doing that, we can see that everything is still working. The same principle applies if we, for example, set the script source to something other than self, such as google.com. If we try that, then we can see that no script is loading at all.
We are not fetching anything from google.com, of course, and it's not loading. We only want to accept scripts from ourselves to ensure that no external malicious scripts can be injected into our app. Now, I may need a few reloads. Let's just close the app and open it again, and then we should see that everything is working properly. By now, actually committing this metatag ensures that we are also safe regarding our content security policy, which is another great step towards a more secure app.
Even though, as I already mentioned a few times, for our app this isn't really as necessary, it is for many apps. So, let's just add it anyway so that if you try to build anything based on this tutorial, you have a good starting point that you can build all of your apps upon.
Now, we could theoretically finish our app. We've got a front end and a back end that communicate in a type-safe manner, and we already have all the information that we need. However, that's not everything I want to show you. I also want to demonstrate features like hiding items to the system tray. Basically, when you close the window, it doesn't just close the app; it pauses it and hides it in the system tray.
To do that, we need to add an icon to the system tray. Before we start implementing this, we first need to go over some requirements for defining an icon. I have defined two icons, and we will go into the Windows one real quick. As you can see, this is a colorful icon that I have defined to ensure that the contrast is really good on Windows.
If you look at its info, we can see it's 16x16 pixels. This size is not arbitrary; it is the default icon size for Electron. Both of these icons are 16x16 pixels, but you might want to have more resolution to create something like the V Media Player icon, which uses more than 16x16 pixels. To achieve this, you can add extensions to your icon name.
For example, my current icon name is icon.png. If you add @2x to your icon name, then Electron will recognize that this is not 16x16; it is 32x32. Similarly, @4x would indicate 64x64 pixels. This way, you can define larger resolutions for your icons while still letting Electron know which resolution to use.
Unfortunately, Electron doesn't just read the resolution of the image and scale it appropriately. If we were to use a really large icon, it would overflow and get cut off at the tray, resulting in a poorly displayed icon. Therefore, if you want to create a large icon, please remember to add @2x, @4x, @5x, or whatever is appropriate to the end of your icon name.
You might have already seen that my other icon, which I will use on macOS, is called tray icon template. The term "template" serves the same purpose; you can add "template" to the end of your icon name to turn it into a template image.
To create stunning icons for your app, remember: use the right resolutions and leverage template features for Mac to ensure your icons always look sharp and professional, no matter the background.
In order to define resolutions in your icons and inform Electron about the appropriate resolution to use, it is important to note that Electron unfortunately doesn't just read the resolution of the image and scale it appropriately. If a really large icon is used, it would overflow and essentially get cut off in the tray, resulting in a really awful looking icon. This occurs because Electron treats it as if it were 32x32 pixels and does not scale anything, which is, of course, not ideal.
To create a large icon, please remember to add 2x, 4x, 5x, or whatever suffix is appropriate to the end of your icon. You might have already seen that my other icon, which I'm going to use on macOS, is called tray icon template. The term template is actually the same thing; you can add template to the end of your icon to turn it into a template image. However, this will only work on macOS. Template images have a few other requirements: they can only consist of black and Alpha Channel pixels, meaning they should be a PNG with a transparent background.
If you take a look at this, you can see that the background of these pixels is transparent, and all pixels are completely pure black. This is how you define a tray icon that is a template icon. But what does it actually do? If you examine these macOS icons, you will notice they are typically always black and white. This is because there is a built-in feature in macOS that changes the color of your icons depending on the background color.
For instance, if I have a very dark desktop background, the bar at the top appears black, and I want all my icons to be white. The tray icon template will replace all the black pixels with white ones if the background is dark, ensuring there is a really good contrast. The same principle applies for a light background; the black pixels remain unchanged. For example, the icon from the VLC media player will turn black when used on a light background.
Electron offers this feature if you call the icon template at the end, and you can still combine this with all the other features for DPI density. By simply calling it as icon name template add 2x.png, you can define large icons that remain consistent on macOS, as they change color when necessary. This is a lot of theory about defining an icon, but it allows you to create a really good looking icon on any operating system.
On Windows, I would always recommend using a bit of color to ensure you have all the contrast needed, as Electron does not provide any template feature on Windows. Conversely, on macOS, you should use the template feature for a coherent and visually appealing icon.
Now that we've covered this theory, let's proceed to use my two icons in our Electron app to implement a height to tray functionality. First, we need to inform Electron where our asset files, specifically our tray icons, are located. Electron Builder will need to know to include these files in the final app. I have added these files in the source/assets directory, where my two PNGs are located.
Next, we can navigate to the Electron Builder configuration file. Here, under our extra resources, which includes everything that is not code, we can add the line: Source/assets/*. This will recursively add every folder and file under the assets directory into the final bundle.
Now, we can define a function in our path resolver to access these asset files. We will add a function called get asset path, which will join the app path. In development, we stay in the current directory, while in production, we go up one directory, following the same pattern as before. We will then specify Source/assets, allowing us to use this in our main.ts to create the tray.
We will create a new tray, which we can import from Electron. The tray takes one parameter, which is the tray icon. We will call get asset path and append the necessary path. We need another path.join to ensure cross-platform compatibility, and we will join it with tray icon.png. We will implement the differentiation between macOS and Windows shortly, but for now, let's try this out to see if the colorful tray icon pops up in the top right corner.
Mastering the art of cross-platform icon management in Electron is key to creating a seamless user experience.
In our production environment, we begin by going up one directory, as we do with everything else. You can observe the same pattern up here, and then we proceed to access the Source SL assets. At this point, we can already utilize this in our main.ts to create the tray. We will create a new tray, which we can import from Electron. This tray requires just one parameter: the tray icon.
To set this up, we will call get asset path and, of course, we need to append the desired path. This requires another path.join to ensure cross-platform compatibility. Here, we will join tray icon.png. We will implement the differentiation between Mac and Windows shortly, but for now, let's test this to see if the colorful tray icon appears in the top right corner.
Indeed, we can see that the icon is now visible, but currently, we are using the colorful icon designated for Windows and Linux, rather than the black and white template icon for Mac OS. To achieve consistency with our other icons, we need to make a simple adjustment: we will check if process.platform is equal to darwin. Essentially, when we are on Mac OS, we want to display one icon, while on Windows or Linux, we will use another. Specifically, if we are on darwin, we will use tray icon template.png, and if we are on Windows, we will use tray icon.png.
After making these changes, we can restart the application quickly, and we should now see the correct icon displayed. This is a significant achievement, as we can now control which icon is shown. To ensure everything is functioning correctly in the opposite direction, let's switch the icons so that only Windows has the black and white icon. Now, we can see the colorful one, which is absolutely perfect.
Furthermore, we want to test the proper template functionality. I will run npm run dev again and open the system settings to change my desktop background to a lighter color. This will allow us to observe how the template icons respond to changes in background color. Currently, I have a dark wallpaper, so let's switch to a solid light color. As expected, the icons turn black because the background is light. This demonstrates the purpose of using template icons, as they adapt responsively to changes in the environment. If I had an icon that was solely white, it would look terrible against this light background.
Now that we have established the tray, we also need to implement a hide tray functionality. For this to work, we need to enable Electron to understand that when I close all windows, I do not want to terminate the entire application; rather, I want to retain the tray icon and be able to click on it to reopen the app. Currently, if I close this window, the tray icon disappears because the entire Electron process stops.
Our next step, before implementing more tray functionalities, is to understand how Electron handles window closures. This understanding will help us implement the hide tray functionality. The simplest way to approach this is to instruct Electron not to execute its default behavior when all windows are closed. Specifically, when I close the window, I want to prevent the app from stopping.
To do this, we will first listen to the close event on the main window. When the app is closed, we will receive an event and execute event.preventDefault(). This means that when the close event is triggered, we will prevent the default behavior and do nothing. However, although this sounds promising, if we try it out, we will find that it does not work as expected. In fact, we will be unable to close the app at all, and we cannot even quit the app using the quit command in Electron. This command attempts to close all windows and then quit the app, and if it cannot close all windows, Electron will give up. Consequently, the only way to close the app at this point is by using Ctrl+C in the terminal.
To resolve this, we need to explore which events we actually need to use. This is somewhat complicated, so let's delve into some theory. To understand how Electron determines when to close, we must recognize that there are multiple ways to close an Electron app. The first method we want to prevent involves...
Understanding event order in Electron is crucial: know when to close your app and when to keep it running.
Even though this sounds really great, if we just try it out, we're going to see it doesn't really work the way we expected. Currently, I actually can't close the app at all. It's not just that we can't quit the app using quit electron; the issue is that it will try to close all windows and then quit the app. If it can't close all windows, then Electron will just give up. So right now, we can only close it using Ctrl + C in the terminal.
Now, what events will we actually need to use here? Well, it's a bit complicated, so let's get into some more theory. To understand this, we first need to explore how Electron knows when to actually close. There are multiple ways to close an Electron app. First of all, the one we want to prevent is closing all windows. If we close all windows and didn't implement anything, then Electron will just quit.
There’s, of course, a way of calling app.quit, which will first try to quit the app, then close all windows. When that is successful, the app will actually terminate. This is basically what we saw when we hit close on Electron. Lastly, there's the automatic way. For example, there are features in Electron like an auto-updater that, when you start the app, checks if there is an update. If there is, it will directly close the app, install the update, and restart the app. This is not something we're going to tackle in this course, but it is a feature we need to keep in mind so that we don't break it.
Functionally, it’s basically the same as app.quit because if you take a look at the event order, you will see that depending on what reason Electron uses to close itself, the event order will be different. If we close all windows, we will first get the event that the windows closed, then a before quit event will be triggered, which tells us that Electron is about to quit if everything is successful. After that, the app will actually stop.
However, if the app is quit directly or automatically, then the before quit event will happen first. Electron will then try to clean up everything by closing all windows, and if that is successful, the app will stop. So, basically, what we need to do is check if the before quit event happened before my main window.close event happened. If so, we will close the window and stop the app. But if my window.close event happened first and then the before quit event occurs, then I don't want to quit the app because the before quit event only happened because I closed the window.
In summary, we need to check if the before quit event happened first. If so, we will close the app; if not, we will prevent the app from closing. This is exactly what we are going to implement now, so you can understand what I'm talking about and get into a simple yet somewhat complex way of how Electron handles events and how event order can change depending on how the event was created.
To do all of this cleanly, we will first define a function called handleCloseEvents, which will need our main window, which is a browser window. We are going to do all of this inside this function to keep it cleaner. To persist what event happened at what point in time, we also need some kind of variable, which we will call let willClose = false because, by default, we don't want the app to close.
Before we move on, let's actually use this app and call this function using our main window. We will also implement the hide to tray functionality in a way that actually hides the window. Currently, if you try this out again, you will see that the window doesn't close, but it also doesn't get hidden away. To achieve that, we will call one more function, which is mainWindow.hide(). This will do everything we need on Windows and Linux, but not quite everything on macOS.
Let’s try this out using npm run. If we do this and click on the X, you will see that the window is gone, but the tray icon is still there, so the app is still running. The taskbar item is also still there, which we probably don't want to happen. Remember, this only happens on macOS; on Windows and Linux, you probably won't see a taskbar icon. This is because there is a variable on the app that you only set on macOS. Therefore, we can say if app.dock exists, which is responsible for handling the macOS dock, then we want to call app.dock.hide() as well.
Mastering app behavior on different operating systems is key to a seamless user experience.
To enhance the functionality of our application, we will call one more function, which is main window.height. This function will actually do everything we need on Windows and Linux, but it may not cover all scenarios on Mac OS. So, let's try this out using npm run dev. If we execute this and click on the X, you will see that the window is gone, but the tray icon is still there, indicating that the app is still running. Additionally, the taskbar item remains visible, which is probably not the desired behavior.
Remember, this issue only occurs on Mac OS; on Windows and Linux, you likely won't see a taskbar icon. This is because there is a variable in the app that is only set on Mac OS. We can now check if app.dock exists. Essentially, if there is a dock property on our app, which is responsible for handling the Mac OS dock, we want to call app.hide as well. We need to perform this check to ensure that the dock property exists, preventing any null pointer errors.
Now, if we try this again using npm run dev, we can see that our hiding functionality is working correctly. When we click on the close button, there is no taskbar icon for Electron anymore, but the tray icon remains present. Now that we have this properly set up, let's delve into our will close functionality.
The easiest approach for now is to say that if we want our app to close, all of the functionality we just built becomes irrelevant. Therefore, we will simply add a return statement early in the code, before any of the other actions occur. This means we won't perform any hiding or other actions; we will just allow Electron to proceed with its default behavior if will close is true.
Next, we need to determine when we set will close to true and when we set it to false. We previously discussed the quit event, so let's add that. There is an event on the app that runs before the app quits, which doesn't directly involve the window. We can now specify that when the before quit event is triggered, we set will close to true.
If the close action occurs first and the before quit event runs afterward, will close will be false for this if clause. The default behavior will be prevented, and will close will be set to true afterward, but since nobody listens for it, this is acceptable. Conversely, if the before quit event runs first, then will close will be true, the default will not be prevented, and Electron will close the app as expected.
However, if we hide the window and then show it again, will close will still be true. This means that regardless of how we close the window, the app will close, which is not the desired outcome. Therefore, we also need to reset this value when our window is shown again. When we click on the tray icon to reopen our window, we can use the function mainWindow.show(). Each time we want to display our main window, we will reset this variable by setting will close to false.
Now, let's test this out by running npm run dev. The simplest action we can take is to directly call app.quit(). You will see that the tray icon disappears and the app closes, effectively quitting the application. This behavior is the same as the auto-update process or directly invoking app.quit().
Next, let's try the opposite scenario by closing our window and confirming that the app does not quit. When I click to close the window, the tray icon remains visible, and there is no dock icon because we called app.hide(). Currently, we cannot close the app in any way because we haven't implemented that functionality yet.
The next step will be to implement this so that our hide tray functionality still allows us to close the app and also to reopen the window, as right now, it seems lost. To start, we will move our tray logic into a separate file to better separate our concerns. We will create a file called tray.ts and define a function called export function createTray(). This function will need a reference to our window, which we will use later, so let's set that up with mainWindow: BrowserWindow to ensure we always have access to it.
Now, we can take the new tray method and place it inside this function. We will refine this shortly, but first, we will create the tray instead of the main function, just as we did before. We will pass the mainWindow reference and import everything we need in this new file. Now, let's execute npm run dev again to see the changes.
Simplifying your code structure not only enhances readability but also boosts functionality—like turning your app into a tray icon with just a few clever tweaks.
To improve the organization of our code, we will create a separate file for our tray functionality. First, let's create a file called tray.ts and define a function within it named export function createTray. This function will require a reference to our window, which we will utilize later on. Therefore, we will define mainWindow and browserWindow to ensure we always have access to everything, as we need to show our window when we click on the tray.
Next, we will take the new tray method and place it inside this function. We will make some adjustments shortly, but for now, we will create the tray instead of a main function. We will pass mainWindow as a parameter and import everything we need in this file. After that, we can run the command npm run dev, and we should see that our tray is still popping up, but now it is organized in a separate file for convenience.
At this point, we can reference the tray by declaring const tray = new Tray and then set the context menu for the tray. The context menu will be created using Menu.buildFromTemplate, where we will provide an array of options that we want to be clickable. To start, we will add one option with a label called quit. When this option is clicked, it will trigger a callback that runs app.quit.
We also need to import app into our file, which we will fix by removing the semicolon. Now, we should be able to close the app from the context menu. Let’s test this by running npm run dev again. After waiting a few seconds, we can see that there is a quit option available, and clicking it will close our app due to the handleClose functionality we implemented earlier.
Next, we will test the reverse scenario by closing our window first, effectively minimizing it to the tray. If we then click quit, we will see the context menu disappear, and our app will remain closed.
Now, we need to implement a show functionality, as we want to be able to display our window again when we click on the tray. To do this, we will add another label called show in a new object. We will add a comma to ensure everything remains functional. For the click action, we will execute multiple commands: first, we will call mainWindow.show. This should work on both Windows and Linux, so let's test it quickly. We should see that we can now hide the app to the tray, and upon clicking show, the window will reappear. However, we notice that the dock icon is still missing, which we likely want to address.
For macOS functionality, we need to check if app.dock is available. If it is, we will also call app.dock.show to ensure that the dock icon reappears. Let’s run npm run dev one last time. After hiding the window and showing it again, we can confirm that the item is now visible in the dock, and we can also quit the app by clicking the tray icon.
This covers the essential tray functionality we need. The most critical aspect is managing when to close the app and when not to close it. Setting the context items is relatively straightforward, and there are additional features we can implement with context menus, such as adding submenus.
For example, we could include checkboxes or radio buttons that allow us to read their states, or we could add separators to enhance the app's appearance. Additionally, we could create submenus to group related options under a specific headline, such as switch view with all the views listed beneath it. However, for our current use case, we will implement a simpler solution with just the show and quit functionalities, which we have successfully integrated.
Moving forward, we will continue using the Menu.buildFromTemplate functionality to create a custom menu. This custom menu will appear at the top left on macOS or directly under the frame where we can close the app on Windows. Let’s explore how to create custom menu items for this purpose.
Creating a custom menu in your app is all about understanding platform differences and leveraging them for a seamless user experience.
Creating a Custom Menu in Electron
In our project, we are implementing a functionality that switches views. While we could have used "switch view" as a headline with all the views listed underneath, we are opting for a different implementation. For our use case, we only need a show and a quit functionality, which we have now implemented.
Next, we will focus on the menu. We will utilize the build from template functionality to create a custom menu. This menu will appear on the left side of Mac OS or directly under the frame where you can also close the app on Windows. Let's explore how to create custom menu items for this purpose. Fortunately, creating a custom menu is quite similar to creating a tray menu.
To get started, we will move our code into a new file named menu.ts. This file will export a function called create menu, which for now will not take any parameters. In this function, we will call the menu from Electron and use setApplicationMenu to define the menu for our entire application, rather than for a specific window.
We will use the build from template method and pass an array of items we want in our menu. However, there is a significant difference between the application menu and the tray menu. For instance, if we compare it to VS Code, we notice that it has various menus with submenus. In contrast, for a tray menu, we typically define only one layer of items. Therefore, we will need to create a menu that contains a submenu to ensure our actions function correctly.
To begin, we will create an object with a label called app. This label will be of type submenu. We can then define a submenu within this object, which is essentially the same as a normal menu. We can add options here, such as a label called quit. When this option is clicked, we will call app.quit.
Next, we will import the app from Electron and add this functionality to our application. We will call create menu without any parameters and run npm run def. Upon doing this, we should see our menu appear at the top left, labeled electron. However, this label will not be visible if you are testing on Linux or Windows.
Now, let's check if the quit functionality works. When we click quit, we can confirm that it does work. The reason for the different behavior on Mac OS is that it has a default setting that requires the first item to always display the name of the app. To work around this, one could add a placeholder item at the front with a label like placeholder or simply leave it as an empty space, setting its visible property to false. This would create an empty item that we would always hide.
If we reload the menu, we will see that the first item, previously labeled electron, has disappeared, leaving only the app name. However, I do not recommend this workaround, as it may break in future updates. Instead, a better approach would be to use a conditional statement: if process.platform equals Darwin, we should not set a name at all, allowing Mac OS to set the name itself. Otherwise, we would call it app for Windows and Linux.
By following this method, we ensure that the menu will always reflect the default Mac OS behavior, regardless of any Electron or Mac OS updates. This approach is particularly advantageous as it allows us to add basic functionalities, such as quitting the app, directly to the initial option.
Finally, we can add a second option, which we might label view, to enhance the functionality of our custom menu.
Simplify your app's menu for a cleaner user experience while keeping essential features accessible only in development mode.
To handle the application process, the platform should be checked. If it equals Darwin, then do not set a name at all and just leave meos to set the name itself. Otherwise, call it app. So, on Windows and Linux, it will be called app, and on Darwin, it will take the default name. Independently of any Electron or MacOS updates, you will always have the default MacOS behavior here, and Electron won't overwrite anything. This is actually the way that I would recommend handling this, especially because you will probably add some basic functionality like quitting the app in here anyway.
This feature is an awesome addition to the initial option. You can also add a second option right here, which you might call something like view. For example, you can then be able to change the view. We could say something like, "I have a view for the CPU," and we will just remove the click for now because the functionality will actually be implemented afterwards. Then, I will have one for the RAM and, of course, one for storage.
Now, if we retry this again, we can see that we now have two items in here, one of which is Electron. If I just get it to work properly, sometimes the highlighting doesn't really work, and you don't get the menu. That's just Electron things basically behaving not the way they should but just the way they do. Now, we can see we've got Electron up here and then our view with CPU, RAM, and storage, and of course, the quit functionality on our Electron bit still works. However, what we've done is actually gotten rid of the functionality to open up the dev tools, which we were able to do previously.
Let's also implement that again, but in a slightly different way because you probably don't want your production use to be able to access them. We are going to implement a custom functionality where those dev tools are only accessible in Dev mode. On click, we will now need the main window, so main window is of type BrowserWindow, and we'll just pass it through real quick. Then, on click, we can now just call a function that runs main window.webContents. We should have something like openDevTools, and if we now call this, then our dev tools should open up in the main window, and we should be able to still access everything the way we did previously.
Let's just try this out real quick, and then we should see that everything is still working properly. Let's hit show here again, and now we're going to call dev tools right here, and our dev tools are going to open up. Of course, we also need the visible feature, which I already showed you, and we just say this is only visible in Dev mode. So now, if I just try to build my app and run it real quick, then we're going to see that this is now called Electron Course because as long as we're not in Dev mode, this is going to be the actual name of our app, while in Dev mode, it's just going to be called Electron, at least on MacOS.
We can see that we now only have the quit functionality, while running in Dev mode will allow us to actually see the dev tools and access them, which is probably what you want because you might not want your users to just tinker around in your app as much as possible. So, enabling the dev tools only in Dev mode does make sense.
Of course, I promised to also show you how to disable the menu completely, so let's just do that real quick as well by building this create menu bit right here. You need to do this following bit outside of the app.onReady function because, for some reason, it will only really work if you do it before the app is initialized. Afterwards, you will have to do some more tinkering to actually disable the menu because Electron will already have initialized it, and setting it to null will not work properly.
So, outside of app.onReady, we will just call Menu.setApplicationMenu again and set it to null. If we now try this out, hitting npm run dev again, then we're going to see that our menu is now reduced to the absolute minimum. On MacOS, that will be basically just our quit bit right here, so you can still close the app but nothing else. On Windows or Linux, you will basically not have an application menu at all, which is also probably what you want.
That's just a little workaround for basically disabling this menu, but in most cases, you might at least want some basic functionality in the menu. I wouldn't really recommend completely disabling it anyway, especially because you probably want your dev tools to work in Dev mode. So, you might at least want to call this conditionally or something. But anyway, this is essentially how you can set a custom application menu.
Simplifying your app's menu can streamline user experience, but remember to keep essential functionalities accessible.
To now try this out, we will hit npm run def again. We will see that our menu is now reduced to the absolute minimum. On Mac OS, this will basically just show our quit button right here, allowing you to close the app but nothing else. On Windows or Linux, you will basically not have an application menu at all, which is also probably what you want. This is just a little workaround for disabling this menu. However, in most cases, you might at least want some basic functionality in the menu, so I wouldn't really recommend completely disabling it. Especially because you probably want your dev tools to work in Dev mode, you might at least want to call this conditionally or something.
Anyway, this is essentially how you can set a custom application menu. We are now going to comment this down here again, of course. Our next step, after importing again, is to actually add the functionality for our CPU, RAM, and storage view change. This will need some IPC communication, so this is going to be our next step: to set up communication to basically tell the front end to switch between the CPU stats, the RAM stats, and the storage stats in the basic UI. Since we've already set up a lot of IPC helpers, this is actually quite easy to do.
We will define a click functionality for the CPU real quick. Here, we can just go ahead and say IPC web contents send. In this part, we will need to define an event, so let's just get into our types real quick and define our new event as well. First of all, we need to define a payload; this is just going to be a type called View, which will be CPU, RAM, or storage, just like in any other location where we're using these values. Then, we can define a new event: change view will be our event name, and of course, the payload will be a view.
Now, we will add it to the window by allowing the front end to subscribe to the change view event. Subscribe change view could be a good name. The type is, of course, going to be view, and the parameter is going to be called View. It will, of course, just like any other function, return an unsubscribe function.
Now we can see our menu right here. We can define what event we want; we, of course, want the change view event. Then, we need to reference our web contents; our main window is already a parameter here, so we can just pass that through. Our payload will, of course, be the word CPU. Now, let's just do the same thing for RAM and storage as well by copying over these names at the end. Now we are already sending the events, but of course, we also need to receive them.
If you take a little look at our preload script, we should see that it now throws an error because we didn't subscribe to the change view event in here. So let's just do that real quick by hitting this and saying subscribe change view. Then, we need to listen to change view in here, and now all the types are complete. We can just hit into our front end and actually try to subscribe right here in app.tsx.
In here, we'll just add a little useEffect, and we can now call window.electron.subscribe change view. Here, we'll just add a callback that, for now, console logs the view. Let's just do that real quick by calling this over here View and, of course, adding our dependency array, which doesn't contain any dependencies. We just return the return value of subscribe change view, which is the unsubscribe function itself, so the cleanup will be done automatically this way.
Now, if you try this out, we should see that when we click on change view, something should be logged to our console. Let's just try that out real quick. Now we just hit storage over here, and we can see our value is being received on the front end.
Now, let's actually implement everything we need so that our front end will update the current view, so that we no longer see the CPU statistics but, for example, the storage or RAM. Let's just copy all of this a few times, calling this RAM usage and the variable will be RAM usages, and then storage usage and storage usages.
What we're going to do now is define a useState: const activeView, setActiveView = useState
, and in here, we can now just say this is of type view. The initial value will be CPU, so when you open up the UI, it's always going to be on the CPU. Now, we'll just add a useMemo: const activeUsages = useMemo
for optimization's sake. Then, we'll actually implement our memo, and of course, we need a dependency array. In here, we're now just going to implement a switch case for our different values of activeView. In the case that it's CPU, I want to return...
Customizing your app's window frame can elevate its functionality while giving it a unique look. Don't settle for default—make it yours!
To begin with, we need to address the CPU statistics. For example, we will also consider storage and RAM. Let's just copy all of this a few times and call this RAM usage. The variable will be named RAM usages and similarly, we will create storage usage and storage usages.
Next, we will define a state using const activeView
and set it with useState
. In this case, we can specify that this is of type view, and the initial value will be CPU. Thus, when you open up the UI, it will always default to the CPU view.
Now, we will add a useMemo
for optimization purposes. We will set active usages equal to useMemo
, and we will implement our memo. Of course, we need to include a dependency array. In this array, we will implement a switch case for our different values of activeView. For instance, in the case that it's CPU, I want to return CPU usages. We will copy this for all the other cases and ensure that we add all of these to the dependency array so they update correctly.
Once we have added all of this, we should be able to pass our active usages into our chart. If we restart quickly by hitting npm run dev
again, we should now see that we can switch our views as expected. However, I did forget to update my useEffect here because when the view changes, we want to set the active view instead of just console logging it. So, let's try again by restarting our app, and now we should see that our view updates correctly.
Next, we will focus on two native features: the tray and the menu. We will also move away a bit from native features as we will remove the entire frame from our window. This will allow us to create a window that is either see-through or has a custom frame, similar to VS Code, which does not use the default Mac frame. We have the search bar here, so let's play around with this a bit.
Creating custom window frames is an excellent way to demonstrate how Electron utilizes small features to maintain full functionality while customizing the app as much as possible. We do need our window to remain draggable, even after removing the standard window frame from the system and implementing our own.
Before we proceed with that, let's first remove our frame. To do this, we will start our app with npm run dev
. We will notice that there is a default frame around the window. On Mac OS, we have items on the left, while on Windows, they are on the right, and we also have a title. We will be getting rid of all of this. However, remember that on Windows and Linux, this will often remove the menu as well. If you want the menu, you will either need to reimplement it completely or use the default frame.
Now, let's get into actually removing the frame, which is quite simple. In the main window creation, we can pass a parameter called frame and set it to false. This is a straightforward way to disable the frame completely. However, this leaves us quite limited; we can't move the window, and while we can still resize it, we can't close it or interact with it in any meaningful way. We can still quit the app, which is nice, but we need to create our own frame to interact with the window.
To do this, we will head into our UI right now. I will copy over some code because defining some CSS isn't particularly interesting. We will first add a header into our app that will contain three buttons: one to close, one to minimize, and one to maximize, just like any other normal app. This will be inside a header for easy styling, as it essentially serves as the header of the app.
Next, we will head into our CSS and style it. You could remove all of the default CSS, but we will do that later when we try to make our UI look better. For now, we will add more CSS on top of what we have and clean it up later. First, we will style our header so that it is positioned at the top. We want to ensure that all of the default styling does not apply; we don't want to center it or anything unnecessary right now. Therefore, we will position it absolutely at the top.
Designing a sleek app header is all about clean styling and intuitive functionality. Keep it simple, make it draggable, and ensure your buttons are responsive without cluttering the interface.
In this tutorial, we will structure our application similarly to any other normal app. The header will serve as the main frame or menu bar, making it easier to style. We will begin by heading into our CSS to apply styles. Initially, we will keep the default CSS, but we plan to remove it later when we focus on making our UI visually appealing. For now, we will add more CSS on top of the existing styles and clean it up later.
First, we will style our header. The header will be positioned at the top of the screen, as we do not want any default styling to apply. We will avoid centering it, as that would be unnecessary at this stage. Instead, we will position it absolutely at the top edge of the screen with a full width. We will also add some padding and set the box-sizing to border-box to prevent overflow. The text alignment will be set to the left, ensuring that all buttons are left-centered. If you are building for Windows, you might want to align the text to the right. Additionally, we will set a visually appealing background color for the header.
To see our progress, we can run the command npm run Def
. Upon doing so, we will observe a dark bar with several buttons inside it. Next, we will focus on styling these buttons. We will remove all default styling from the buttons, make them round, and set their width and height to one rem, along with some margin for spacing. The buttons will be colored with aesthetically pleasing colors similar to those found in Mac OS, allowing for easy differentiation. After implementing these styles, we can take another look and see that our buttons are now visible. However, clicking them will not produce any action yet, as we have not added any on-click functionality.
The next interesting aspect is how to inform Electron that this is our header and that we want to drag the app when we drag this header. This process is quite simple; we just need to add one property, webkit app region, to our header and set it to "drag." This will enable us to drag our app using the header. If we want to prevent dragging on the buttons—so that users can click them without accidentally dragging the window—we can copy this property and set the webkit app region for the buttons to "no-drag." This way, users can click the buttons while still being able to drag the entire window by dragging the header.
It is important to note that Electron allows us to implement specific UI features that enhance the functionality of a normal app, even when overriding some default Electron behaviors, such as hiding the frame. Additionally, if we enable the frame by setting it to true, we will notice that Electron limits our ability to drag the window. In this case, the webkit app region will no longer function as expected, so it is essential to keep this in mind.
Now, let’s move on to enabling IPC communication so that our buttons can perform actions. The interesting part about this event is that we will send data from the front end to the back end without expecting any response. Essentially, we will allow the front end to communicate with the back end to maximize, minimize, or close the window without needing a reply.
Before we implement this feature, we need to define a new type in our mapping, which will be called Frame Window action. This action can either be to close, maximize, or minimize the window. We will then incorporate this into our payload map, allowing us to send frame actions. Our frame action will simply be a Frame Window action. Additionally, we will need to create a function on our window that will be called send frame action, which will send a payload in the form of a Frame Window action.
With these steps, we will be on our way to creating a functional and visually appealing application.
Empower your front end to command the back end without waiting for a response—just send the action and let it flow.
To facilitate communication from the front end to the back end without expecting any return, we essentially allow the front end to instruct the back end to perform actions such as maximizing, minimizing, or closing the window. The back end is then free to execute these commands without the need for a response.
Before we proceed with the implementation, we first need to introduce a new type in our mapping. This type will be a Frame Window action, which can either be to close, maximize, or minimize the window. We will also need to incorporate this into our payload map, allowing us to send a frame action. The frame action will be defined as a Frame Window action. Additionally, we need to create a function on our window called send frame action, which will send a payload in the form of a Frame Window action.
Next, we need to implement the necessary components to make this functionality work. Our preload script is already prepared, but we haven't yet implemented the send method. Let's quickly address that. The send frame action function will accept a payload and perform the necessary actions with it. Currently, we can only invoke events on the front end, so we need to enable sending events as well.
To do this, we can copy the IPC setup and modify it. We won't need a callback; we just need the payload. We can simplify this by calling IPC.send with the payload, ensuring that all other generics remain unchanged. Now, we can implement a type-safe send wrapper. We can go back to where we are currently not handling the payload and simply call IPC.send with our send frame action and the payload. This ensures that everything is typed correctly according to our type file, which specifies that this is a Frame Window action.
Now, let's move to our app and assign the appropriate actions to all buttons. For the close button, we will set it to execute window.electron.do.sendFrameAction and pass close as the argument. We will replicate this for the other buttons as well. With the front end now complete, we need to implement a listener on the back end to handle these actions.
We will return to our main.ts file, where we can set up an IPC main on listener, which we have yet to implement. We will copy the existing IPC main handle setup and rename it to IPC main on. This listener will not return anything, so it will return void, and we will need to accept a payload in our handler, which will be of type event payload mapping key. This allows us to pass through our values while ensuring that we validate the frame to maintain the integrity of the sources.
In our handler, we will forward the payload, and we can rename the handler function to on instead of handle. Now, we have an IPC main on listener ready to be imported and configured with the necessary parameters. We will set up the send frame action listener with the payload and implement a switch case to execute different commands based on the payload received.
For the first case, if the payload is close, we can simply call mainWindow.close(), which will trigger all of our close logic, similar to clicking the close icon on the window. We will break out of this case after executing the command. Next, we will implement the same logic for maximize and minimize, calling mainWindow.maximize() and mainWindow.minimize(), respectively.
With these implementations, we should see that all buttons now perform their expected actions: the minimize button minimizes the window, the maximize button maximizes it, and the close button closes the window without quitting the app. This is achieved through our custom handle close events function, which operates as intended.
In summary, we have established several IPC events that facilitate data transmission from the front end to the back end, as well as some that allow for full communication in both directions.
Mastering Electron means mastering communication—between front end and back end, and between tests and features.
To maximize the functionality of our application, we will implement a function that will actually call main window.maximize. The same approach applies to the minimize function, which will also call main window.minimize. Now, we can basically test this out, and we should see that all of these buttons will now perform as expected. Specifically, the minimize button will minimize something, the maximize button will maximize, and the close button will close it without actually quitting the app. This is because it runs into a custom handle close events function down here, which is essentially what we want.
At this point, we have established a few different IPC events: some send data from the front end to the back end, some go the other way, and some facilitate full communication from the front end to the back end and back again. These are basically the main versions of communicating in Electron, which we have now handled. As you can see, Electron offers a lot of custom functionalities. We can create a tray, develop custom menus, and even eliminate the default functionality entirely by removing the default Mac OS or Linux frame and building our own. This allows us to tap into a wide range of functionalities, although we haven't explored nearly all of them.
You could also utilize some system default uploading or downloading functionalities for files, etc. All of these features are well documented in the Electron documentation, and we don't have the time to cover all of them, as that would take forever. Additionally, new features are added regularly.
Now, we will shift our focus from examining the specific features of Electron to discussing how you can actually test an Electron app, as this is not as simple as it might seem. Before we can delve into writing our tests, we first need to look at the different types of tests we can write. The main types include unit tests, component tests, and end-to-end tests. We will go through each of these to determine their relevance to our app.
Starting with end-to-end tests, this involves completely starting up the app and running tests inside of it. This will be our primary focus, as we want to test the integration of a UI within Electron and also evaluate some of its features. Having full end-to-end testability allows us to effectively test all the Electron features in combination with the UI, which is ideal for this course and for many functionalities that involve Electron. Two of the main libraries you could use for end-to-end testing are Cypress and Playwright. However, Cypress currently does not offer functionality for Electron embeds, which means it cannot open an Electron app or display multiple windows. On the other hand, Playwright does allow you to start everything up in an Electron context, enabling you to open windows and execute special Electron-specific code. Therefore, we will use Playwright for our end-to-end tests.
Next, we have component tests, which can also be written using Cypress, Playwright, or with a testing library in a unit test format. However, we will not focus on these tests because when using Cypress or Playwright, the code is essentially the same. You simply indicate that you want to render a component. As the name suggests, component tests primarily focus on front-end code, and since we want to test our Electron code, this approach does not make sense for our situation. That said, you could certainly use component tests if you were building a full app with complex components that needed testing.
Lastly, we have unit tests, which can be written using libraries like Vite, Mocha, or others. These tests can be applied to both the front end and the back end. We will focus on the back end, particularly on features that we cannot test in end-to-end tests, as our Playwright end-to-end library for Electron does not support all of Electron's features, such as our tray.
In summary, our testing strategy will involve using Vite for unit tests focused on features that cannot be tested with end-to-end tests, employing end-to-end tests for the general functionality of our app, and avoiding component tests since they are primarily used for complex UI features that we do not have and do not wish to test.
Now, let’s begin by writing a simple end-to-end test to get started and explore how all of this works. However, before we can proceed with writing any tests, we first need to set up our testing environment.
Focus on the backend testing strategy: prioritize unit tests for unsupported features and streamline your end-to-end testing setup.
In this discussion, we are focusing on the back end, particularly on features that we can't test in end-to-end (e2e) tests. This is due to the fact that our Playwright integration with the Electron library does not support all the features that Electron offers, such as the tray functionality. Therefore, we are making specific decisions regarding our testing strategy. We are using Vite for unit tests on features that cannot be tested using end-to-end tests, while we are employing end-to-end tests for the general functionality of our app. We have decided against using component tests, as they are primarily utilized for complex UI features, which we do not have and do not wish to test.
To get started, we will begin by writing a little end-to-end test to understand how everything works. However, before we can write any tests, we first need to set up Playwright and configure it to work with Electron and Vite. We will start by running the installation wizard for Playwright using the command npm init playwright
. This will prompt us to specify where we want to place our test files; I will name the folder e2e (end-to-end) to avoid any confusion with our unit tests.
Next, the wizard will ask if we want to set up GitHub Actions. The default response is no, and since we will not cover GitHub Actions in this course, we will stick with no. Lastly, it will inquire about installing the default browsers: Firefox, Chromium, and Safari. For our use case, we only need Chromium for Electron, and honestly, not even that is strictly necessary. However, some browser needs to be installed for everything to function properly, so we will proceed with Chromium.
After this, we can run the command npx playwright install chromium
. For me, this will happen instantly because I have already installed it, but for you, it might take some time as it will install up to 100 megabytes. This is also why we are not installing any other browsers, as it would be a waste of resources. Now, we have the end-to-end folder set up, along with the test examples. The test examples serve merely as a syntax reference, and since we will implement everything ourselves, we can delete these examples.
Next, we can head into the package.json file and add a new script called test:end-to-end (or whatever you prefer to call it). In this script, we will simply call playwright test
to run the default tests, which will check some default settings on the Playwright site. Now, if we run npm run test:end-to-end
, we should see that the tests are running and everything is functioning correctly. However, we are encountering four errors. This is because it attempts to run the tests in Firefox, WebKit, and Chromium, but we only have one browser installed.
To resolve this, we need to access the Playwright configuration file. Here, we can see the projects section, which specifies the browsers in which we want to run our tests. Since we do not need any of the other browsers, we will stick with Chromium. We can either comment out or completely remove the entries for Firefox and WebKit. After making these changes, if we start the tests again, we should see that they pass with two green tests.
Now, we can proceed to connect this setup to our Vite instance. As a reminder, when we start our development environment, we spin up the Vite server and the Electron app, which connects to the Vite server to enable hot module reloading. We want to ensure that the app is running in development mode and is connected to the Vite server, as we do not want to build the app every time we want to run a test.
To accomplish this, we need to instruct Playwright to spin up the Electron app, but before doing so, we must run the web server that the Electron app will connect to. We will comment in the web server section of the configuration and specify the command that runs the web server, which is npm run dev
. This command will start Vite. In our Vite configuration, we can check the port, which is 5123, and we will paste this into our configuration.
However, when we try to run the tests, we will notice that it does not work. The process will essentially hang because it is waiting for the Vite server to start up. The tests will only run if the server starts successfully, which, unfortunately, it does not appear to do at this point.
To run your Electron app smoothly, always ensure your web server is up and configured correctly before launching tests.
To begin with, we need to instruct Playwright to spin up the Electron app. However, before doing this, we must ensure that the web server the Electron app will connect to is running. To accomplish this, we will comment in the web server section and specify the command that runs the web server. Upon reviewing, we find that the command is npm run def react, which will effectively run V.
Next, we can check our V configuration to identify the port, which is 5123. We will paste this information and attempt to run it. Initially, we might encounter an issue where it seems that nothing happens. This is because it will wait for the V server to start up and will only execute any tests if the server has started successfully. Unfortunately, it appears that the server did not start as expected.
The problem arises because V is trying to access a specific URL, which, if we attempt to open, will not work. This is due to V expecting to use Local Host rather than an IP address. While we could configure V to use the IP address, it is more sensible to simply rename this to Local Host: 5123, quit the script, and run it again. Now, we should see that the tests start up correctly, and the web server will run in the background, which is essential for the Electron app to function properly.
Before we can execute an Electron test, we must initialize our Electron app and the main window prior to each test. Fortunately, there is a helper function in Playwright called test.beforeEach. Here, we can pass an asynchronous function that will execute before each test runs. Additionally, we can utilize another helper from Playwright, underscore electron, which enables us to interact with our Electron app.
To start, we will launch Electron using specific arguments. We first pass the argument "." and set the environment to develop, mirroring what we have in our package.json. This dot signifies that our Electron app resides in the current directory. When we run npm run def electron, we build Electron, set the environment to develop, and then run Electron from this directory.
Next, we will await electron app.firstWindow, which allows us to wait for Electron to open the main window before commencing the tests. We can then execute npm run test end-to-end, and we should observe that our tests successfully open this window before closing it, as the normal tests run through without issues.
Additionally, you might notice check marks and a play button, which enable running tests independently. This feature can be obtained by installing the Playwright Test extension for VS Code, which can significantly aid in debugging, so I recommend installing it as a helpful tool.
Now, returning to our tests, we need to ensure we clean up after ourselves. Each time we run a test, it opens an Electron app that may not close properly, potentially leading to memory issues. To address this, we will use test.afterEach and pass another asynchronous function where we will await electron app.close. However, since the Electron app is only available within the beforeEach, we can declare it as a variable at a higher scope by using let electronApp.
With this adjustment, everything should work seamlessly. However, we notice that our Electron app is currently typed as any, which is not ideal. To resolve this, we could attempt to import the type, but that can be quite complex. Instead, we can set the type to be the return type of underscore electron.launch. This approach will almost work, but we must remember that the launch function returns a promise, so we need to await it.
By doing this, we can convert the promise of an Electron application into an actual Electron application type. We can apply the same logic to our main page as well, ensuring that we can effectively interact with our Electron app moving forward.
Testing your Electron app just got easier! By properly handling promises and ensuring your UI elements are ready, you can eliminate flaky tests and boost reliability.
The app appears here and now, and all of this works. However, our election app has an any type issue. To solve that, we could either try to import it, which is actually not as easy as you might think, or we can just set this type to be the return type of the type of uncore electron do launch. This will almost work because we now get the return type of this launch function, but as you can see, we need to await it. The launch function actually returns a promise, and to convert this Promise of an electron application into an electric application, we can simply say we want the awaited return type. Now, this is basically of the type electron application.
We can actually do the same thing for our main page as well. Of course, when we want to interact with our electron app, we will run queries against this page. To make that work, we need to get the page outside of the test before each block as well. To do that, we'll just do the same thing real quick. As you can see, this is really similar; we just replaced electron launch with electron app.first window. By building up on this type, we can now get this type and use the main page any way we want. Our after each block is now working perfectly as well because we can now access the electron app.
Now let's get into actually writing some tests on our electron app instead of on the https playwright. To get started, we'll interact with the frame we've just built by clicking our custom minimize button in the UI. First of all, we need to run a click event on a certain tag on our main page. Because we've given the tag the minimize ID, we can now just interact with this item by running main page.click with the #minimize to get the tag with the minimize ID and click it.
Of course, we also need to find out if the app was actually minimized. For that, we can run electron app.evaluate, which will give us an option to not only interact with the UI but also with electron internal code. This is done by passing a callback with the electron object, which we can now interact with. To do that, we can say, "Okay, from my electron app, I want to get the browser window APIs and I want to get all the windows." Since we only have one main window, I want to get the first one and its is minimized value. Now, I can use this is minimized value outside of this evaluate block.
The only thing you need to be careful with here is that this can only return JSON formattable stuff; you can't pass something like a function or a date in here. Now that we've done this, we can use this is minimized function in an expect block. We expect is minimized to be true after minimizing. If we try this out by killing our terminal and running this again using npm run test end to end, we should see that the window opens. We even saw it minimize, and now if we move this up a bit, we can see our test passed. So, testing against our UI now works perfectly fine.
We can also do something really similar to check electron internal stuff. Here's a little test for checking if our menu is working. First of all, we run electron evaluate after our window has opened and basically just get our application menu that we've configured and get it as a variable here. This is now an electron menu or null, but because we've set it up, we know it's not null. Just to make sure, we'll first check that it isn't null. Then, we can get the items from our menu, which should be two items, and get the sub-items count to see how many there are. We can then run tests on our label. As you might remember, our label is actually not called select; it is called view. So, let's just change this up real quick.
If we run npm run test end to end again, we should see that our test passes. Let's try this out, and we can see that our test passed perfectly fine. However, at some point, you might realize that some of these tests are a bit flaky because sometimes they tend to flicker a bit. Sometimes they are true, and sometimes they are false. This is the case because electron app.first window will check if our browser window was initialized, which is basically the first thing we're doing. We aren't even loading a URL or executing any of this code down here, and many times we will not reach this create menu all the way down at the bottom.
To prevent this issue from ever happening, we can go into this before each block and wait for a preload script to have been loaded. After all of that, we'll be really sure that most of our electron code will have at least loaded once, so we won't have all that flickering that sometimes occurs.
Stability in testing is key; ensure your setup is solid to eliminate flickering and achieve reliable results.
In the process of testing our Electron application, you might realize that some of these tests are a bit flaky. This is because sometimes they tend to flicker; at times they are true, and at other times they are false. The reason for this inconsistency is that the Electron app's first window will actually check if our browser window was initialized. This is basically the first thing we're doing; we aren't even loading a URL or executing any of the code down here. Many times, we will actually not reach the create menu all the way down at the bottom.
To prevent this issue from ever happening, we can go ahead into the before each block and wait for a preload script to have been loaded. After all of that, we will be really sure that most of our Electron code will have at least loaded once, thus avoiding all that flickering that can sometimes occur. In this case, we were lucky, but it will not always be this way. So, let's get into implementing that. Luckily, this can be done in quite a simple function as well.
We will create a function that will wait for our preload script to load. Since we will be polling for the preload script, we need to wrap this inside of a promise. As you can see, I will create an interval that will run every 100 milliseconds. When you want to await something in an interval, you'll need to create a new promise. Once you've reached the desired value, we will resolve the promise so that it will not be awaited anymore but will actually return something.
What we will do here is basically just go ahead and say main page to the value. We will not evaluate something in Electron but actually run code inside of the HTML of our main window. Here, we can just go ahead and say that we want to get the window inside of our main page. We actually need to fake the type a bit because we can't use our global type defined in the T config, as Playwright just doesn't use the T config.
What we will do here is get our Electron object that is created using the preload script. Now, using this Electron bit, we can save this as an Electron bridge. If the Electron bridge was initialized, I want to stop the interval, so I will stop polling and resolve this promise, allowing me to move on with my test. Essentially, every 100 milliseconds, I want to check if the window has now loaded the preload script and if my Electron object is initialized. If so, please stop polling and resolve this promise with true so that we can move on.
By doing this, we can just go ahead and put this inside of our before each block, and now the test should stop flickering. I cannot show you that this is now not flickering anymore, but you just need to trust me that this will make your test a lot more stable. As you can see, it's still passing just fine, and it will pass even better if your test didn't work initially. As I mentioned, it can flicker sometimes, so for me, it just worked, but for you, it might not have worked the first time. However, it should work now.
This is basically everything you need to set up to test your Electron app using Vitest. You can now test the UI and test Electron-specific features, but you cannot test every Electron-specific feature. For example, this Electron app cannot properly access the tray. To actually test our tray menu, we will create a unit test using Vitest to ensure that it works as well. This will also demonstrate how you would unit test your Electron apps.
To test our tray, we first need to set up Vitest, which is going to be your unit testing library for this purpose. I would recommend the VS Code extension for better debuggability and also suggest installing Vitest using npm install --save-dev vitest
. After doing that, we should be able to get everything set up. Now that it's installed, we will need to add another script to our package.json called test:unit
, which will run Vitest in the source directory.
Now we can go ahead and navigate to the electron directory to create a new file called tray.test.ts
, which will contain our test for the tray. Here, we can start off by importing some stuff from Vitest. Using this, we can define a name for a test and a callback function that should execute when we run the test. The main thing we are going to do now is actually create a tray, so let's just import createTray from do/tray
, and this will now execute our tray.
Testing is all about creating the right environment; mock what you need, ignore what you don’t.
To begin with, we should be able to get everything set up. Now that it's installed, we'll basically just need to add another script to our package.json as well, called test:unit. This script will run v test on our source directory, allowing us to execute every test within it.
Next, we can proceed to the electron directory and create a new file named tray.test.ts, which will contain our test for the tray. Here, we can start off by importing some necessary components from v test. Using this, we can define a name for a test and a callback function that should execute when we run the test. The main task at hand is to create a tray, so we will import createTray from do/tray. This will execute our tray creation.
If this were an end-to-end test, we would need to set up the app, pass the main window, and find a way to actually test the tray's functionality. However, since we are writing a unit test, we will create a few mocks—keeping them to a minimum—to disable most of the Electron functionality. We will create a custom main window that only includes the functions we need, allowing us to extract all necessary data from the tray and run tests on that.
To get started, we need some kind of main window, as createTray expects the main window to be passed. Upon reviewing the implementation, we see that the main window is only used to run the show method. Therefore, we need to create an object that has a show method for our test to function correctly. We will define this as follows:
const mainWindow = {
show: vi.fn()
};
This creates a mock function that essentially does nothing but allows us to test whether the function was executed. We can also define custom behavior for this function, such as a custom return value or implementation. For our use case, it is sufficient to check if this function was called, confirming that our implementation works.
Now, we can pass this mainWindow into createTray. However, we will encounter some type errors because our main window is not of type BrowserWindow. To resolve this, we will cast it as any. To maintain a good development experience, we will also ensure that it satisfies a partial of a BrowserWindow, which will allow us to have good autocomplete features, such as defining a height function inferred from knowing that this should be a partial BrowserWindow.
If we want to ensure that this is recognized as a BrowserWindow, we can cast it to a BrowserWindow after casting it to any. Although this makes our type a bit messy, it allows us to have good autocomplete both inside and outside of this object. Now, mainWindow.show is recognized as an existing function.
To test this, we can go into our createTray function, temporarily remove the default implementation, and run mainWindow.show. We should be able to verify that mainWindow.show was called. We can do this by using the following expectation:
expect(mainWindow.show).toHaveBeenCalled();
If we run this test using npm run test:unit, which we defined earlier, we should see that our test passes. If we stop the show method from executing by commenting it out in createTray, we will see that our test fails, which is the expected outcome.
Now, let’s revert all of this by uncommenting everything and proceed to implement all the necessary mocks. This will ensure that we do not attempt to run any Electron code but only execute the code needed to interact with the menu that createTray generates. The goal of this test is to verify whether the functions, specifically the click functions within the show and quit blocks, perform as expected. To achieve this, we need a way to access the menu built from the template function. Thus, we will mock it, retrieve the parameters we pass to it, and run tests on those parameters. However, to do that, we first need to mock the tray since Electron will...
Mocking is the key to effective unit testing; it allows you to isolate and validate functionality without the overhead of running the entire application.
We'd expect so. Now, let's actually reverse all of this by commenting all of this back in and getting to actually implementing all of the mocks we need. This is necessary for the code to basically not try to run Electron code but only run the code we need to get through here and interact with the menu that we create right here.
The goal for this test is to check if the click functions inside of the show and quit blocks actually do what I'd expect them to. Therefore, we need some kind of way to get into this menu that is built from the template function. We will basically mock it, get the parameters that we pass to it, and then run tests on these parameters.
However, to do that, we first need to mock the tray. If Electron tries to actually spin up an app to create a tray, that will fail because, inside the context of a unit test, we can't spin up an app. We also need to ensure that a mocked tray does indeed have a set context menu function; if that doesn't exist, we will run into a null pointer issue.
Once all of this works, we can mock the menu built from the template and then get the parameters of this mock to interact with it inside our tests. Luckily, Vite test also has a really nice mocking library. We just have vi.mock
, and here we pass the name of the library we want to mock—in this case, this is going to be Electron. We then pass in a function, and whatever this function returns will now be the stuff that is exported from Electron.
If we go ahead and pass an empty object here, then all of the inputs in our tray will not work because we are essentially telling Electron that it does not export a browser window, a menu, or anything else; it exports nothing. Therefore, we need to define everything that Electron should export.
Looking at our implementation again, we can see that we need a tray, which needs to be a function because a constructor is basically nothing else but a function. This function needs to return a tray, so let's start by defining the tray right here. Since we want to have some custom implementation, we'll define this as a vi.fn
, and this will have a mock return value. Whatever we pass into this mock return value will now be what is returned when we call Tray. In this case, this should be an object.
If we take another look at the implementation, the tray will then be the tray, and the only thing we run on the tray is actually set context menu. Thus, our mock will need to have a set context menu method, which, once again, is just the vi.fn
. Now we should almost be at a point where set context menu is called.
However, if we examine our test and make it a little bit bigger, we will see that app.getApp
doesn't exist because app isn't defined. The implementation actually needs this app because it's passed as a parameter to a new tray. Therefore, we can't access this set context menu function as long as this app isn't defined in getAssetPA
. So, we now need to define an app and actually define the function that is used here, which is, of course, getAppPA.
Let's go ahead and say our app is an object, and this object has a getApp function, which is also a vi.fn
with a mock return value of /
. Now we can see that we're actually failing when we run menu.buildFromTemplate because the menu doesn't exist. At least we got to the point where our tray.setContextMenu was not called.
Let's proceed to mock this function so that we can hopefully go through this whole thing without running into any errors. We will define our menu as an object, and the only thing we call on it is buildFromTemplate. Again, buildFromTemplate is just the vi.fn
. Now we see that our test still fails, but this is not the case because we still expect mainWindow.show to have been called, which, of course, doesn't happen.
Now, let's change this up quickly by stating that we want menu.buildFromTemplate to have been called. We, of course, need to fix this by actually importing menu from Electron. Now we can see it's waiting for file changes, and it passed because this was actually called.
We can now run logic on it by checking what parameters were given to buildFromTemplate and what functions I actually passed, and do these functions do what I expect them to? To achieve this, we will need some way to get all the calls that happened on buildFromTemplate. All the parameters that were given can be easily accessed by stating const calls = menu.buildFromTemplate
.
Testing your code is like a safety net for your logic—make sure every function does what you expect before it hits the real world.
In this section, we will discuss the process of testing a menu built from a template using Electron. Initially, we expected the main window show to have been called, which, of course, didn't happen. To rectify this, we decided to modify our approach by stating that we want menu.buildFromTemplate to have been called. We also needed to quickly fix this by actually importing menu from Electron. After making these changes, we observed that it was waiting for file changes, and the test passed because the function was indeed called.
Next, we needed to run logic on the parameters given to buildFromTemplate and the functions we passed to it, ensuring these functions behaved as expected. To achieve this, we required a method to capture all the calls that occurred on buildFromTemplate, including the parameters provided. This was straightforward; we simply declared const calls = menu.buildFromTemplate.mock. However, we encountered an issue because mock didn't exist. This was due to our inability to instruct the unit test to adopt a different type using the mock statement.
To resolve this, we placed parentheses around the mock and used as any to eliminate the error. However, a more effective solution was to use as any as Mock and then import mock from vitest. By doing this, we obtained the call object, which represented our entire mock context rather than just the calls. To isolate the calls, we accessed calls directly, and since we knew only one call had occurred, we could proceed by declaring const ARS = calls[0]. To ensure we received accurate types, we defined this as an S of parameters type of menu.buildFromTemplate. This allowed us to understand the types of these arguments, which were either Electron Menu Item Constructor Options or an Electron Menu Item Array.
With this setup, we could now conduct tests on the parameters passed to the template. We decided to remove our current test and define a new one by retrieving the template, which was the first argument of our test. We then asserted that the template had a length of two, corresponding to the labels show and quit. After saving this, we confirmed that our test still passed, as the length was indeed two.
Moving forward, we implemented some logic by stating that we wanted to get the first item of the template and simulate a click on it. We passed three nulls because if everything was removed, it would complain due to the default click function in the menu expecting parameters. However, since we didn't use these parameters in our implementation, we could safely pass three nulls as any, avoiding any null pointer issues. By calling this click function, we expected it to invoke mainWindow.show, and if app.do was defined, it should also call app.do.show.
To test this, we added an assertion stating, I expect mainWindow.show to have been called. Our tests continued to pass. However, when we attempted to apply the same logic for app.do.show, we encountered a failure because app.do didn't exist yet. To fix this, we added app.do as another object with a show function, which was a vi.fn(). We then verified that this function had indeed been called.
Lastly, we needed to check if the second button worked, specifically if it executed app.quit. We revisited our mock setup, as quit was also not mocked yet. We declared quit as a mocked function to track if it was called. Then, we proceeded to run the second item of our template, the quit item, and asserted that clicking it would result in app.quit being called. Our tests continued to pass successfully.
Additionally, we conducted simpler assertions, such as expecting template[0].label to equal show, which also passed since the first parameter we passed to the template was indeed the show function. Through this process, we have demonstrated a comprehensive example of how to effectively use vitest to test functionality in an Electron application.
Testing your code is just as important as writing it. A clean UI is the cherry on top, but solid functionality is what truly makes your app shine.
It actually runs app.quit, and for that, we once again need to go into here because quit is also not mocked yet. So, let's just go ahead and say that quit is also a mocked function. This way, we can read if it was called. Now, we just go ahead, go down here, and run template one, which is the second item of our template, the quit item. We will just check that if we click that, we expect app.quit to have been called. As you can see, our tests are still passing down here.
We can, of course, also do some simpler stuff, like for example, expecting template 0.label to equal show, which will, of course, also pass because the first parameter we're passing to a template is, of course, the show function. By now doing this, we've set a really great example of how you can actually use v-test to test Electron functionalities in case you either can't test them using n-test or don't want to test them using n-test.
Now, we can actually finish this testing bit right here because I think these are good methodologies for using tests in Electron. You can, of course, still do some UI tests as well, but those are not specific to Electron, so I won't go into them here. What we will go into now is actually implementing our UI properly because right now it looks quite ugly. So, let's get into actually making it look nice to really finish up this project nicely.
Before we now move on to the UI, I'd first of all like to say that we're only going to be writing React code from now on. We will, of course, still interact with all Electron code we've written, but we won't write any new Electron code. So, if you were only interested in Electron bits, then you are free to go now. I hope I could help you, and I hope you'll check out more of freeCodeCamp and my stuff. But if you're also interested in seeing the final result of this course, then you're, of course, free to stick around as well.
We will now get into actually developing this UI, and to make sure that you're always seeing what our changes actually do, we will now stay in the split view right here. We will basically be working with app.css, index.css, and app.tsx. We won't be creating new files for better structuring or whatever; we'll keep everything in these three files just so you have a better look into what we're actually doing. Having this bar on the side here would just take up too much space.
To now get started, let's first head into our app.css and actually get rid of all the styling that we didn't create ourselves, so basically anything but the header. Let's just remove this right here, and now if we save, we can already see that all of the stuff is now put on the left right here, and we've lost a bit of our custom styling. Now, let's do the same for the index.css, where we basically remove everything but this root bit right here. We will scroll all the way down and remove all of this.
Now we can see we are almost at the right point. We're only seeing that our header is sticky right now, which is, of course, not ideal. So, let's change that up real quick by removing this position: absolute as well as the top and left properties. Now, if we just take another look, we can see it already looks nicer, but we've got this little margin right here. So, in our index.css, we'll also just go ahead and define the body to have a margin of zero. Now, our app already looks quite pretty.
Let's just get rid of all of this default VReact stuff right here by just throwing that away. Now we've got a blank slate that we can work with. Awesome! My goal is that we basically now have the child on the right and select for the CPU, RAM, and storage on the left. We will first create a little grid where we can put these things inside of.
So, we'll start off by going underneath the header and just adding a div right here that will basically wrap all of this. Then, we'll add another div to have all left side content. Of course, we'll need a class name for our grid now, so main-grid, for example, just so we can get rid of these inline styles, which look kind of ugly right now.
Let's just now go into our app.css and start defining some styles. So, main-grid will have a height of 120 pixels just so it looks the same as it did before. Now, we've basically got a lot better structure as well. We will just take this header right here and also move it into a separate component just so everything looks a bit cleaner.
So, we will create a function called Header, and Header will just return all of our header logic right here. Now, let's just render the header right on top of this, and everything already looks a lot cleaner and is easier to maintain as well. Let's just put a little class name main right here so that we can actually work with our content as well and start styling it. Also, let's just put a 1, 2, 3 in here so you can actually see the changes.
Clean code leads to cleaner designs. Simplify your structure for better maintenance and aesthetics.
Let's get rid of these inline styles which look kind of ugly right now. So, let's now go into our app.css and start defining some styles. The mang GD will have a height of 120 pixels just so it looks the same as it did before. Now, we've basically got a lot better structure as well. We will take this header right here and also move it into a separate component just so everything looks a bit cleaner.
The function header will just return all of our header logic right here. So now, let's just render the header right on top of this, and everything already looks a lot cleaner and is easier to maintain as well. Let's put a little class name main right here so that we can actually work with our content and start styling it. Additionally, let's just put a 1 2 3 in here so you can actually see what we are doing.
Heading back to our app.css, we'll go in here and say do Main. The Main will have a display grid property, and we will define that the grid template columns are 24 R and auto. Basically, the left side will be 24 R in size, and the right side will just grow as much as possible. If we just try to resize this real quick, we're going to see the right side grows, but the left side has a constant width. You could, of course, also do something like fit content, but because we don't actually have any content yet, we will just go with 24m as kind of a magic number.
Now we can actually start defining our content here. For that, we'll create a new little function component which is going to be a select option because we, of course, want to select between the CPU, the RAM, and the storage. We will structure this outside of here just so it's easier to work with, and it will return a little button because it's going to be clickable. So, a button does, of course, make sense, and we'll render multiple of those because we're going to have a CPU, a RAM, and a storage button. For now, we'll just put lauram ipom in here just so these buttons have some content.
Next, we will actually remove the default styling because we, of course, don't want these ugly buttons; we want something that looks nice. Instead of our index.css, we'll also go ahead and say button all unset to basically remove all the default styling from our buttons. Now, they're basically just normal inline elements, and this way we can now just go ahead and actually start implementing these buttons by defining a little title in here, for example, as another div.
Then, we're also going to add a little class name to these buttons to style them. The class name is going to be select option, and we can start off by saying a select option should have a display property of block just so it's basically just like a div and not an inline element. We'll also add some padding to make it look nicer, so 0.5 RAM. Now, all of these are already structured a lot better.
Let's move on by also defining some kind of content. What I'm thinking here is actually adding a little chart in there just to basically always show the statistics of the selected option as well. For that, we, of course, need to now actually add some data in here, so we'll just go with an empty array for now to make everything work, but we will need to actually put some data in here shortly. We also need some kind of a subtitle because what I'm thinking here is having something like this says CPU, and then beside the CPU, we've got the title of the CPU, so something like I5 7500k, for example. This should be next to each other, and to do it, we will, of course, need one wrapper to make it sit right beside each other.
Now we can add all the class names to actually make this look nice. From here, we can now start styling, so let's just copy this over and say we've got a select option title and a select option chart. Let's start off by saying our chart has a height of 100 pixels, for example, and now we can already see it popping up here. We, of course, also want it to take up as much space as possible, so it should have a width of 100%.
We still aren't seeing anything right here because this thing is display block, but it doesn't fill the space, so let's also give it a width of 100%. Now, this already looks kind of nice, even though it's still a bit weird. Let's also style the title real quick by saying this is display flex. Now we are at least getting close to what I want. We will also add a little bit of distance between the two titles, so a gap of 1 RAM.
Now we are at least getting close, so you could see that this says CPU I5, this could say RAM 8 GB, and this could say storage 500 GB, for example. Then next to it, we've got our graph, but I think that we can go with a bit less space, so let's just go with 16 R right here just so our graph is a bit more aligned.
Designing with intention transforms chaos into clarity.
To create a visually appealing layout, we want to utilize as much space as possible, ensuring that it has a width of 100%. However, we still aren't seeing anything right here because this element is set to display: block, but it doesn't fill the space. Therefore, let's also give it a width of 100%. Now, this already looks kind of nice, even though it's still a bit weird.
Next, let's style the title quickly by setting it to display: flex. Now we are at least getting close to what I want. We will also add a little bit of distance between the two titles, creating a gap of one rem. At this point, we can see that this says CPU I5, this could say RAM 8 GB, and this could say storage 500 GB, for example. Next to it, we've got our graph, but I think we can reduce the space a bit, so let's just go with 16 rem right here to make our graph more prominent.
Of course, we also want a little gap in here, so let's set a gap of one rem to space these elements out better. Additionally, we might want to make the charts a bit smaller, so how about four rem? This way, they can show some data but not as much as they currently do. Overall, everything looks a bit nicer now.
We can also go ahead and style our select option title. For the first child, we will set a font weight of 600 to make the CPU title a bit more prominent. The next child, or nth-child(2), should have a font weight of 400 and a font size of 0.8 rem, so it's not as large. Now, we've already got a good setup.
Before we continue styling, we will need some props in here. These props will include the title, the subtitle, and our data for the chart. Having said that, we can already see that these values are read properly. However, we still need to get our static data in here because right now, all of these titles are too docile. We do fetch them from Electron as a static data object, so let's try to read this and write it inside our component.
To get started, we will first need to create a new function to fetch our static data. We will define a useStaticData function that will create a useState to house the static data or null if it hasn't been fetched yet. Then, it will use a useEffect to fetch this static data. We will define an async function that will await window.electron.getData and directly write the result to a setStaticData function. This allows us to return static data from this hook and read our static data.
Let's try this out quickly by going all the way up here and saying const staticData = useStaticData. Now we can start using it to pass values to our select options. For example, the subtitle for our CPU can now be staticData.CPUModel or an empty string as a placeholder while the data is loading. Now we can see our CPU is Apple M1, and we can do the same for the other two options to ensure they look just as good.
For the total memory, we can use totalMemoryInGigabytes.toString() to match the types, and for storage, we will do the same. Now we can see this is an Apple M1, this is 8 GB, and this is 245 GB. We want to format this nicely, so let's add a few parentheses around here and append a little string with space GB at the end for better formatting. We'll do the same for the other data points as well, and now we can see that all of this information makes the layout look much nicer.
Next, let's improve our styling once more by adjusting the styles for the select option. First, we will set a nice-looking background color; I think #C2C2C2 looks really nice. We will also add some margins so they aren't too close together, and add some border-radius because that never hurts. Lastly, we will implement a hover state so that we can see when we're selecting something, and we'll also add a cursor: pointer style to indicate that these elements are clickable.
As you can see, our grid currently prevents this pointer from working, so we need to address that. The easiest solution is to set the cursor to pointer for every child of our option chart. Now, when we hover over this, we can see that our cursor is now correct. Finally, we need to implement the click functionality so that we can control which chart should be displayed here. Luckily, this is really easy because we've already implemented view changing, so we just need to add an onClick function to this button.
Transforming your app's UI can elevate the user experience from basic to brilliant with just a few tweaks.
In this segment, we focus on selecting something and adding a little cursor pointer to enhance user interaction. Currently, our grid prevents this pointer from functioning, so we need to address that. The easiest solution is to set the cursor to pointer for every child of our option chart. Now, when we hover over this, we can see that our cursor is functioning correctly.
Next, we need to implement click functionality to control which chart should be displayed. Fortunately, this is straightforward since we have already implemented view changing. We will add an onClick functionality to the button, which will run props.doOnClick
. Additionally, we will add the type to this button and implement our view changing by setting the active view to CPU, and doing the same for the other two options.
Having completed this, we can now click on these options, and the chart on the right updates based on our selections. By adjusting a few settings, such as changing the height of the select option chart and centering the elements, we can significantly improve the visual appeal. For instance, we can add a margin of one rem to the main option.
To further enhance our application, we can add a custom color for each type of statistics to differentiate them better. Since the color is currently defined in our base chart, we need to propagate it up. We will add a little fill and stroke as strings and pass these through. Here, we will set props.fill
and props.stroke
.
Next, we will define our colors in the chart as a color map, using our types (CPU, RAM, and storage) as keys and assigning the desired colors as parameters. Additionally, we will introduce another prop for the selected view, which will be either CPU, RAM, or storage. We can store our color as a useMemo, pulling from the color map based on props.selectedView
, with dependencies set to props.selectedView
.
Now, we will pass these colors as parameters, where the fill will be color.fill
and the stroke will be color.stroke
. We also need to go up one more layer to pass this into all our charts. For each chart, we will set the selected view to our active view, and for the other chart, we will do the same, ensuring the selected view corresponds to the correct title.
Currently, the types are incorrect because the title is not the right reference. We will change this to view, which will be of type view. After making this adjustment, we can replace title with view and ensure our view includes the strings for storage, RAM, and CPU.
While we notice some repetition of the word CPU, which indicates potential optimization, it should suffice for our use case. The last improvement involves setting a max width of 900 pixels in our app's CSS to enhance the layout when zoomed in, along with a margin auto to center the content.
With these adjustments, we now have a visually appealing application. We can change views from the dropdown, and all the colors update accordingly, creating a coherent and user-friendly interface. Although it’s not perfect, it serves as a solid foundation for further styling.
As we conclude this segment, I would like to thank you for your attention. I hope this guide has been helpful in your Electron journey, enabling you to build exciting projects. I encourage you to explore more of my content and the fantastic resources available on the FreeCodeCamp channel. Have an awesome day, and goodbye!
Subscribe to my newsletter
Read articles from Igor Berlenko directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by