Webux Lab

By Studio Webux

Fun with Electron 13 and vue 2

TG
Tommy Gingras Studio Webux 2022-01-22

Fun with Electron 13 and VueJS 2

This is a simple proof of concept; I want to create a small portable application to use on top of a nodejs utility. Nothing is planned or in progress as for now. I simply wanted to test and learn the basics of electron.

I’ll be using VueJS 2 to code the frontend; Mostly because this is the framework that i’m the most familiar with. I do not plan integrating a backend, because I’ll target AWS directly with the aws-sdk v3

Note

This approach enables the nodeIntegration, this is not recommended. Please look : https://www.electronjs.org/docs/latest/tutorial/security#2-do-not-enable-nodejs-integration-for-remote-content

[2022-02-01]: I’ll try to add the correct and secure approach soon.

Prerequisites

I’m using a Mac V11.6 (Big Sur) with npm 6.14.15 and node 14.18.1.

ESLint is configured but for some reasons it is all messed up on my local machine.


Setup

I’ve just learned how to do all these, so it might be possible that the procedure/code can be improved. (I’ve around 12 hours of experience with Electron and so far I like that, it is well documented and this is fast and easy to deploy, it works great with vue and I can iterate quickly)

Install and create the structure

npm init -y
npm install electron --save-dev
npm install -g @vue/cli
vue create my-project
cd my-project/
vue add electron-builder

Start the project

npm run electron:serve

Tested with success on MacOS and Windows 10

Build your application

npm run electron:build

Then navigate to dist_electron/mac/my-project.app to launch the app on your local machine. (I noticed some issues when doing that on MacOS)


Backgroud.js

src/background.js

// Requires Tray, ipcMain and Menu
import { app, protocol, BrowserWindow, Notification, Tray, ipcMain, Menu } from "electron";
import { createProtocol } from "vue-cli-plugin-electron-builder/lib";
import installExtension, { VUEJS_DEVTOOLS } from "electron-devtools-installer";

const isDevelopment = process.env.NODE_ENV !== "production";

// Define the tray outside the functions, to avoid the issue that duplicate the Tray multiple times.
let tray;

// Scheme must be registered before the app is ready
protocol.registerSchemesAsPrivileged([
  { scheme: "app", privileges: { secure: true, standard: true } },
]);

// Using notification to notify the user
// This function print a message when the application starts
function showNotification() {
  new Notification({ title: "Spaghetti", body: "Howdy Chief !" }).show();
}

async function createWindow() {
  // Create the browser window.
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      // It is required to be able to use the remote module within the application
      enableRemoteModule: true,
      // Use pluginOptions.nodeIntegration, leave this alone
      // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info
      nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
      contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
    },
  });

  if (process.env.WEBPACK_DEV_SERVER_URL) {
    // Load the url of the dev server if in development mode
    await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL);
    if (!process.env.IS_TEST) win.webContents.openDevTools();
  } else {
    createProtocol("app");
    // Load the index.html when not in development
    win.loadURL("app://./index.html");
  }
}

// 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 (BrowserWindow.getAllWindows().length === 0) createWindow();
});

// 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", async () => {
  if (isDevelopment && !process.env.IS_TEST) {
    // Install Vue Devtools
    try {
      await installExtension(VUEJS_DEVTOOLS);
    } catch (e) {
      console.error("Vue Devtools failed to install:", e.toString());
    }
  }

  createWindow().then(() => {
    // Show the notification when the window is created
    showNotification();
    // initialiaze the tray with an icon and a tooltip
    tray = new Tray("src/assets/logo-tmp-yellow.png");
    tray.setToolTip("Grrrr");
  });
});

// Create a listener to be able to update the tray from the code
ipcMain.on("update-tray", (data) => {
  const contextMenu = Menu.buildFromTemplate(data);
  tray.setContextMenu(contextMenu);
});

// Exit cleanly on request from parent process in development mode.
if (isDevelopment) {
  if (process.platform === "win32") {
    process.on("message", (data) => {
      if (data === "graceful-exit") {
        app.quit();
      }
    });
  } else {
    process.on("SIGTERM", () => {
      app.quit();
    });
  }
}

I’ve added few comment in the code above.


I also had to create this configuration below, to allow us to use fs, path and os within the vue code/application.

Based on the documentation, you must be very careful when enabling this flag.

vue.config.js

module.exports = {
  pluginOptions: {
    electronBuilder: {
      nodeIntegration: true,
    },
  },
};

Tray (The top right bar on macOS)

I’ll try to keep it simple, so my challenge was to update the tray from the vue code. Here is how I did it: See above for the background.js and vue.config.js code and configuration

To update the Tray you must do that from your vuejs code (I did the update using the vuex store):

The snippet below is a copy/paste from the application I’m working on.

src/store/modules/tray.js

// import the remote module to communicate
import { remote } from "electron";

const state = {
  trayLoaded: false,
};

const mutations = {
  CLEAR_TRAY(state) {
    console.debug("CLEAR_TRAY");

    state.trayLoaded = false;
  },

  TRAY_LOADED(state, b) {
    console.debug("TRAY_LOADED");
    state.trayLoaded = b;
  },
};

const actions = {
  clearTray({ commit }) {
    console.debug("clearTray");

    // Here you must use remote.ipcMain and emit to the listener created in background.js, 
    // it represents the default value
    remote.ipcMain.emit("update-tray", [{ label: "Please select an AWS account" }]);
    commit("CLEAR_TRAY");
  },

  updateTray({ rootGetters, commit }) {
    console.debug("updateTray");
    // Here you must use remote.ipcMain and emit to the listener created in background.js
    remote.ipcMain.emit("update-tray", [
      {
        label: rootGetters.accountId,
        type: "submenu",
        submenu: [
          {
            label: "Pipelines",
            icon: "src/assets/pipes.png",
            submenu: [
              ...rootGetters["Pipeline/pipelines"].map((pipeline) => ({
                label: pipeline.name,
                icon:
                  pipeline.state === "Succeeded"
                    ? "src/assets/ok.png"
                    : pipeline.state === "InProgress"
                    ? "src/assets/loading.png"
                    : "src/assets/fail.png",
              })),
            ],
          },
        ],
      },
    ]);

    commit("TRAY_LOADED", true);
  },

  initialize({ dispatch, commit }) {
    console.debug("Initialize Tray");
    commit("CLEAR_TRAY");
    dispatch("updateTray");
  },
};

const getters = {
  trayLoaded: (state) => state.trayLoaded,
};

export default {
  state,
  mutations,
  actions,
  getters,
  namespaced: true,
};

In summary, in order to avoid having the issue with the Tray that has been replicated few times, and being able to control the Tray from the vue code, you must use the ipcMain and send an event.


Notifications

I didn’t do much yet with this. But I’ve tried it on Windows 10 and MacOS with success.

Currently for testing purposes, I’ve integrated the notifications with the error handling

/src/store/modules/errorHandling.js

import { remote } from "electron";

const state = {
  error: "",
  message: "",
};

const mutations = {
  SET_ERROR(state, error) {
    state.error = error;
  },
};

const actions = {
  setError({ commit }, error) {
    commit("SET_ERROR", error);
    console.error("SET_ERROR", error);
    new remote.Notification({ title: "Spaghetti", body: error.message }).show();
  },
};

const getters = {
  error: (state) => state.error,
};

export default {
  state,
  mutations,
  actions,
  getters,
};

Bootstrap and icons

For CSS and Icons, I’m using bootstrap 5.1.

main.js

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

import "bootstrap";
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-icons/font/bootstrap-icons.css";

Vue.config.productionTip = false;

new Vue({
  router,
  store,
  render: (h) => h(App),
}).$mount("#app");


Localstorage

Currently I’m using the browser localstorage to store persistent information, I’ll move to sqlite or something better later on.

src/utils/localStorage.js

const saveLocalStorage = (key, value) => {
  console.debug(`Save ${key} to local storage`);
  localStorage.setItem(key, value);
  return key;
};

const getLocalStorage = (key) => localStorage.getItem(key);

const deleteLocalStorage = (key) => {
  localStorage.removeItem(key);
  return key;
};

module.exports = {
  saveLocalStorage,
  getLocalStorage,
  deleteLocalStorage,
};

AWS Credentials

The goal of the application is to interact and provides tools to work with AWS from our local machine and uses our local credentials, that said here is the utility script to parse the AWS credentials file:

src/utils/awsCredentials.js

import { readFileSync } from "fs";
import path from "path";
import { homedir } from "os";

const checkHomePath = ({ customPath }) => {
  if (!customPath) {
    return customPath;
  }
  if (customPath.includes("~")) {
    return customPath.replace("~", homedir());
  }
  return customPath;
};

const readCredentials = async ({ customPath }) =>
  readFileSync(checkHomePath({ customPath }) || path.join(homedir(), ".aws", "credentials"), {
    encoding: "utf8",
    flag: "r",
  });

const convertToArray = ({ rawCredentials }) => {
  if (!rawCredentials || rawCredentials.length === 0) {
    throw new Error("Missing credentials content");
  }
  return rawCredentials.split("\n");
};

const determineSection = ({ profile, rawCredentials }) => {
  if (!profile || profile.length === 0) {
    throw new Error("Missing profile");
  }
  if (profile && (!profile.startsWith("[") || !profile.endsWith("]"))) {
    throw new Error("Profile must starts with '[' and ends with ']'");
  }

  if (!rawCredentials || rawCredentials.length === 0) {
    throw new Error("Missing credentials content");
  }

  const credentialsArray = convertToArray({ rawCredentials });
  const start = credentialsArray.findIndex((line) => line.includes(profile));
  if (start === -1) {
    throw new Error("Profile not found in credentials");
  }
  const end = credentialsArray.slice(start + 1).findIndex((line) => line.startsWith("["));
  return { start, end: start - 1 + end, section: credentialsArray.slice(start, start + end) };
};

const extractOneProfile = async ({ profile, rawCredentials }) => {
  const { section } = determineSection({ profile, rawCredentials });

  const extractedCredentials = {
    profile,
    accessKeyId: null,
    secretAccessKey: null,
    sessionToken: null,
    mfaSerial: null,
    sourceProfile: null,
    roleArn: null,
    hasMfa: false,
    assumeRole: false,
  };

  section.forEach((line) => {
    if (line.includes("aws_access_key_id")) {
      extractedCredentials.accessKeyId = line.split("aws_access_key_id = ")[1].replace(/\s/g, "");
    }

    if (line.includes("aws_secret_access_key")) {
      extractedCredentials.secretAccessKey = line
        .split("aws_secret_access_key = ")[1]
        .replace(/\s/g, "");
    }

    if (line.includes("session_token")) {
      extractedCredentials.sessionToken = line.split("session_token = ")[1].replace(/\s/g, "");
    }

    if (line.includes("mfa_serial")) {
      extractedCredentials.mfaSerial = line.split("mfa_serial = ")[1].replace(/\s/g, "");
      extractedCredentials.hasMfa = true;
    }

    if (line.includes("source_profile")) {
      extractedCredentials.sourceProfile = line.split("source_profile = ")[1].replace(/\s/g, "");
      extractedCredentials.assumeRole = true;
    }

    if (line.includes("role_arn")) {
      extractedCredentials.roleArn = line.split("role_arn = ")[1].replace(/\s/g, "");
      extractedCredentials.assumeRole = true;
    }
  });

  if (extractedCredentials.assumeRole) {
    const sourceProfile = await extractOneProfile({
      profile: `[${extractedCredentials.sourceProfile}]`,
      rawCredentials,
    });
    extractedCredentials.sourceProfile = { ...sourceProfile };
  }

  extractedCredentials.credentials = {
    accessKeyId: extractedCredentials.accessKeyId,
    secretAccessKey: extractedCredentials.secretAccessKey,
    sessionToken: extractedCredentials.sessionToken,
  };

  return Promise.resolve(extractedCredentials);
};

const extractProfiles = async ({ rawCredentials }) => {
  if (!rawCredentials || rawCredentials.length === 0) {
    throw new Error("Missing credentials content");
  }

  return Promise.all(
    convertToArray({ rawCredentials })
      .filter((opt) => opt.startsWith("["))
      .map((profile) => extractOneProfile({ rawCredentials, profile }))
  );
};

export { readCredentials, convertToArray, determineSection, extractProfiles, extractOneProfile };

Conclusion

In summary, I’ve covered some challenges I had to setup and use electron 13 with vue 2.

Sources


Search