Electron: Free your Main Process!

Ever wanted to run your app's Main Process script from somewhere outside of your project's directory? Now you can!

ยท

6 min read

Contents:

Some time ago, I had an idea to take my Electron application to new heights - server-side updates.

Having a completely network-updatable app using something like JSDelivr, where users wouldn't need to download a whole new EXE every time I included a typo/mistranslation/code oversight, would be an incredibly useful feather in my cap.

I had tried this in the past (using another service that was not JSDelivr), but it didn't turn out well:

For one, as Electron's main process JS file (and by extension, files linked within - such as index.html) was pretty much required* to be run from the project's directory (or a path relative to it), I was only able to link the project's renderer process script externally via the HTML file, which results in less than half of the entire app being able to be updated and served to the user - which obviously, isn't ideal.

*I say "pretty much required", as the "main" field in the package.json file only accepts relative paths (such as ./ or ../) - so you could run it from elsewhere. But what if your EXE isn't located in the exact same place on a user's system, or not even on the same drive? Your paths would break, that's what!

Secondly (and more embarrasingly), the service I used in lieu of JSDelivr in some kind of giddy, impulsive, zero-research decision, hit me with a massively unhelpful bandwidth limitation for the hosted JS files a few months into release. Along with this lack of foresight and seemingly endless amounts of idiocy, I also provided zero fallback options if the scripts were somehow renderered unavailable, which in turn, stopped the app from working altogether - which then resulted in a Friday night of removing everything I had previously done to include such a feature, and re-releasing a newer, non-updatable version on GitHub much later that evening with my tail between my legs.

Now having learned from this mistake, I still needed a solution to issue #1 - how can we run Electron's main process from another location?

Using Modules

I decided to implement a technique I had seen used previously in another project on GitHub, and tweaked it a bit to serve my purpose. The project in question was only linking to a subdirectory of its own, but what if we could use environmental variables instead?

The theory behind it is this: convert your actual main process into a JS module, export it, and import/require it into a "dummy" main process (which is located in the app's root directory). This way, as long as the path to the actual main process module is valid in the dummy process, you can load it from anywhere on the system you like!


1. Create a "Dummy" Main Process File
In your app's root directory, create a new JS file and call it whatever you want (dummy.js, appentry.js, blorknub.js - it's all good!). image.png

2. Edit "package.json"
Open your package.json file and edit the "main" field to use this file - e.g. "main": "./dummy.js",

3. Create a "main.js" File
Create a basic main process file somewhere on your system - I'll create a directory in C:\Users\%username%\appdata\local\test for demonstration purposes. image.png

4. Convert the Main Process Into a Module

Disclaimer: There are several ways to do this more "professionally", but for ease of demonstration, we will convert the entire main process into a single module.

To convert the main process into a module, create a variable containing an anonymous function (e.g. const startapp = () => { }), then put all your main process code inside the function. Once done, export this function (e.g. module.exports = startapp()) at the end of the script - congratulations, you've just created a JS module!

const startapp = () => {
    const { app, BrowserWindow } = require('electron')
    const path = require('path')

    let win

    function createWin() {
        win = new BrowserWindow({
            width: 800,
            height: 600,
            webPreferences: {
                nodeIntegration: true
            }
        })

        // Note: "__dirname" will not work here, as your assets are
        // stored in a different folder (i.e. your project folder),
        // but you can use Node envars here instead, such as:
        // `${process.env.INIT_CWD}` (non-complied project directory)
        // `${process.resourcesPath}/app` (electron-builder app resources dir)
        win.loadFile(path.join("project","folder","path","index.html"))
    }

    app.on('ready', () => {
        createWin()
    })
}

module.exports = startapp()

5. Import the Module Into The Dummy Process
In your project folder, open up your dummy JS file (e.g. dummy.js) and import your newly created module.

const { app } = require('electron')
const path = require('path')

// We can now use envars to import our module from anywhere!
var startapp = require(path.join(process.env.localappdata,"test","main.js"))

app.on('ready', () => {
    startapp
})

To provide a fallback (if, for example, the linked file is somehow removed from the location we asked it to require from), we can add a simple try/catch block:

try {
    var startapp = require(path.join(process.env.localappdata,"test","main.js"))
} catch (err) {
    console.log("Error: Remote module could not be loaded! " + err)
    var startapp = require("./main.js")
}

Just make sure to also include the same "main.js" module file in your project folder to validate the fallback code!

Limitations and Workarounds

As noted in the remote main.js code above, the __dirname variable (usually used to get the path to the project's root folder) cannot be used when the main process file does not exist within the same directory.

i.e. If __dirname is used in a file existing within the project's root folder (as normal), it will resolve to that path (e.g. C:\Users\%username%\Documents\MyElectronProject). Conversely, using __dirname in a file outside of this directory would resolve to that directory (e.g. in this case, it would resolve to: C:\Users\%username%\AppData\Local\test).

To combat this, there are a few built-in environmental variables provided by Node JS that enable us to link back to the original directory (for fetching of assets/npm modules etc).

For this, we can use the process variable.

process contains environmental variable paths which are relative to the paths on our system (and will change based on the device it is running on). process is built into Node JS, so we don't even need to require it!

The most useful process paths to use in order to resolve the issue with __dirname would be:

process.env.INIT_CWD - Gets the path where the script was initialised, but only works when the project has not yet been compiled (i.e. via script in package.json or by using npm electron .).

process.resourcesPath - When using electron-builder and compliling your app to portable EXE format, process.resourcesPath (without .env) will resolve to the app's compiled resources directory. Add "/app" to the end of the path to point to the actual directory where the app resources are stored.


To actually use this information without getting super overwhelmed, we have a few choices:

  • Copy all assets (index.html,styles.css,renderer.js and any additional subdirectories - minus the node_modules folder) to the same folder as main.js (e.g. in this example: C:\Users\%username%\AppData\Local\test). This way we can still use the __dirname variable, but any additional npm modules will need to be required using the project's original path:
// Uncompiled
const somemodule = require(path.join(process.env.INIT_CWD,"node_modules","somemodule"))

// Compiled
const somemodule = require(path.join(process.resourcesPath,"app","node_modules","somemodule"))
  • Change all instances of __dirname in main.js to the applicable process path:
// Uncompiled
document.getElementById('img').src = path.join(process.env.INIT_CWD,"img","img.png")

// Compiled
document.getElementById('img').src = path.join(process.resourcesPath,"app","img","img.png")

To check out all the available options when using process, just log it to the console and expand the object with console.log(process) - there's a lot of cool options in there!

Whichever way you choose, congrats - you're now able to run your main process from anywhere on the system!

If you enjoyed this article or found it useful, please consider donating on Ko-Fi - any donations are massively appreciated!

If you play Steam games, check out my main project, Steam Achievement Notifier on GitHub!

ย