How to load images in Electron applications

Loading images can be tricky to load in Electron apps. In development environments, we want a hot-reload server, something that will recompile our application if we make changes. In production environments, we would like all of our code to be bundled and distributable. Are we able to load images in a way that is agnostic of the target environment? There are many others asking this same question, and it turns out we can load images regardless of the environment in an Electron application.

💡
There are some varying suggestions that are definitely not secure, or slightly more complicated than they need to be. Your mileage may vary if you are looking for an up-to-date example online!

In order to support loading images in development and production environments, we will leveraging Webpack and custom protocols. I am assuming as part of this blog post that you are using the Webpack dev server to host/rebundle your application when developing/testing locally.

👍
A working Electron application template with the changes proposed in this blog post can be found here.

Configure your Asset Modules

Starting in Webpack 5, Asset Modules are the new way that replace the raw-loader , url-loader and file-loader. We will be configuring an Asset Module in order to load images (if you are using a version of Webpack prior to version 5, use the url-loader in your configuration file to load images). Add an entry in your module.rules array property to identify image files.

sample webpack.config.js file

const {
  CleanWebpackPlugin
} = require("clean-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const webpack = require("webpack");
const path = require("path");

module.exports = {
  target: "web", // Our app can run without electron
  entry: ["./app/src/index.tsx"], // The entry point of our app; these entry points can be named and we can also have multiple if we'd like to split the webpack bundle into smaller files to improve script loading speed between multiple pages of our app
  output: {
    path: path.resolve(__dirname, "app/dist"), // Where all the output files get dropped after webpack is done with them
    filename: "bundle.js" // The name of the webpack bundle that's generated
  },
  resolve: {
    fallback: {
      "crypto": require.resolve("crypto-browserify"),
      "buffer": require.resolve("buffer/"),
      "path": require.resolve("path-browserify"),
      "stream": require.resolve("stream-browserify")
    }
  },
  module: {
    rules: [{
        // loads .html files
        test: /\.(html)$/,
        include: [path.resolve(__dirname, "app/src")],
        use: {
          loader: "html-loader",
          options: {
            sources: {
              "list": [{
                "tag": "img",
                "attribute": "data-src",
                "type": "src"
              }]
            }
          }
        }
      },
      // loads .js/jsx/tsx files
      {
        test: /\.[jt]sx?$/,
        include: [path.resolve(__dirname, "app/src")],
        loader: "babel-loader",
        resolve: {
          extensions: [".js", ".jsx", ".ts", ".tsx", ".json"]
        }
      },
      // loads .css files
      {
        test: /\.css$/,
        include: [
          path.resolve(__dirname, "app/src"),
          path.resolve(__dirname, "node_modules/"),
        ],
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader"
        ],
        resolve: {
          extensions: [".css"]
        }
      },
      // loads common image formats
      {
        test: /\.(svg|png|jpg|gif)$/,
        include: [
          path.resolve(__dirname, "resources/images")
        ],
        type: "asset/inline"
      }
    ]
  },
  plugins: [
    // fix "process is not defined" error;
    // https://stackoverflow.com/a/64553486/1837080
    new webpack.ProvidePlugin({
      process: "process/browser.js",
    }),
    new CleanWebpackPlugin()
  ]
};
A sample webpack.config.js that details the values necessary to load image files in an Electron application

The important bits to call out here are:

  • The test property needs to include any image file extensions you may be using in your application.
  • The include property should point to the directory all of your images are saved in.
  • The type property should be set to "asset/inline", this exports a data URI of the asset within the bundled Webpack output.

Once the Webpack configuration file has been updated, you can import your image in any of your front-end pages

sample React front-end component

import React from "react";

import img from "../[RelativePathToImagesFolder]/images/testimage.png";

class Image extends React.Component {
  render() {
    return (
      <React.Fragment>
        <img src={img} />
        
      </React.Fragment>
    );
  }
}

export default Image;
A sample React component that renders an image imported from a static directory

These changes will allow you to load images when running your Electron application in non-prod environments, but as soon as we want to run our Electron in a production build, we will see that our solution does not allow us to load images. In order to support loading images in a production build of an Electron application, we need to implement a custom protocol.

Implement a custom protocol

Electron application's UIs are loaded through BrowserWindows. By default, a BrowserWindow can't load local resources unless the webPreferences.webSecurity property is set to false. The webSecurity property is set to true by default to prevent the UI to load any local resource; this is a good default when considering security, but how can we load images while still keeping our application secure?

We can keep our application secure by defining a custom URI scheme that our application loads resources over. It is insecure to load resources over the file:// scheme because in certain situations the CSP can be bypassed. By default, an Electron app will load resources over the file:// scheme, so in order for our applications to stay secure and allow images to load, we need to configure our Electron app to load resources over a different scheme. By defining our custom protocol as standard (shown below, in an image), we allow our protocol to "allow relative and absolute resources to be resolved correctly when served. Otherwise the scheme will behave like the file protocol, but without the ability to resolve relative URLs." (https://www.electronjs.org/docs/latest/api/protocol#protocolregisterschemesasprivilegedcustomschemes).

Fortunately, a great protocol was written by moloch– that we can leverage in our Electron applications.

protocol.js

/*
Reasonably Secure Electron
Copyright (C) 2021  Bishop Fox
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-------------------------------------------------------------------------
Implementing a custom protocol achieves two goals:
  1) Allows us to use ES6 modules/targets for Angular
  2) Avoids running the app in a file:// origin
*/

const fs = require("fs");
const path = require("path");

const DIST_PATH = path.join(__dirname, "../../app/dist");
const scheme = "app";

const mimeTypes = {
  ".js": "text/javascript",
  ".mjs": "text/javascript",
  ".html": "text/html",
  ".htm": "text/html",
  ".json": "application/json",
  ".css": "text/css",
  ".svg": "image/svg+xml",
  ".ico": "image/vnd.microsoft.icon",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".map": "text/plain"
};

function charset(mimeExt) {
  return [".html", ".htm", ".js", ".mjs"].some((m) => m === mimeExt) ?
    "utf-8" :
    null;
}

function mime(filename) {
  const mimeExt = path.extname(`${filename || ""}`).toLowerCase();
  const mimeType = mimeTypes[mimeExt];
  return mimeType ? { mimeExt, mimeType } : { mimeExt: null, mimeType: null };
}

function requestHandler(req, next) {
  const reqUrl = new URL(req.url);
  let reqPath = path.normalize(reqUrl.pathname);
  if (reqPath === "/") {
    reqPath = "/index.html";
  }
  const reqFilename = path.basename(reqPath);
  fs.readFile(path.join(DIST_PATH, reqPath), (err, data) => {
    const { mimeExt, mimeType } = mime(reqFilename);
    if (!err && mimeType !== null) {
      next({
        mimeType,
        charset: charset(mimeExt),
        data
      });
    } else {
      console.error(err);
    }
  });
}

module.exports = {
  scheme,
  requestHandler
};
A custom "app" scheme to use for loading resources securely in an Electron application

DIST_PATH and/or scheme may be modified here to your liking/needs. The DIST_PATH needs to be the same directory that Webpack will output your bundled files in a production-environment build.

Once we've added this file into our application, we need to configure the protocol in our Electron application's main.js file.

main.js

const {
    app,
    protocol,
    BrowserWindow
} = require("electron");
const Protocol = require("./protocol");
const isDev = process.env.NODE_ENV === "development";
const port = 40992; // Hardcoded; needs to match webpack.development.js and package.json
const selfHost = `http://localhost:${port}`;

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win;

async function createWindow() {

    if (!isDev) {
        // Needs to happen before creating/loading the browser window;
        // protocol is only used in prod
        protocol.registerBufferProtocol(Protocol.scheme, Protocol.requestHandler);
    }

    // Create the browser window.
    win = new BrowserWindow({
        width: 800,
        height: 600
    });

    // Load app
    if (isDev) {
        win.loadURL(selfHost);
    } else {
        win.loadURL(`${Protocol.scheme}://rse/index.html`);
    }

    // Emitted when the window is closed.
    win.on("closed", () => {
        // Dereference the window object, usually you would store windows
        // in an array if your app supports multi windows, this is the time
        // when you should delete the corresponding element.
        win = null;
    });
}

// Needs to be called before app is ready;
// gives our scheme access to load relative files,
// as well as local storage, cookies, etc.
// https://electronjs.org/docs/api/protocol#protocolregisterschemesasprivilegedcustomschemes
protocol.registerSchemesAsPrivileged([{
    scheme: Protocol.scheme,
    privileges: {
        standard: true,
        secure: true
    }
}]);

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", createWindow);

// Quit when all windows are closed.
app.on("window-all-closed", () => {
    // On macOS it is common for applications and their menu bar
    // to stay active until the user quits explicitly with Cmd + Q
    if (process.platform !== "darwin") {
        app.quit();
    }
});

app.on("activate", () => {
    // On macOS it's common to re-create a window in the app when the
    // dock icon is clicked and there are no other windows open.
    if (win === null) {
        createWindow();
    }
});
Configuring our custom scheme when running our application with production environment settings
We define here that our scheme is standard

Assuming you too are using the HtmlWebpackPlugin, you will need to configure the base property (which injects a <base> tag in your HTML).

new HtmlWebpackPlugin({
    template: path.resolve(__dirname, "app/src/index.html"),
    filename: "index.html",
    base: "app://rse"
})
Sample values for HtmlWebpackPlugin in order to configure a <base> tag that matches our custom scheme

Setting the base property defines a base URL for all relative URLs. By setting the <base> tag to our custom scheme, our app will now properly load images in a production build.

Our files are being loaded over a custom scheme

Github

A sample project that has these changes can be found at the secure-electron-template repository.