Building Desktop Applications with Electron and Vue 3

Required Packages

Project Setup

Creating the Vue Application

Ensure Node.js is installed, then run:

npm init vue@latest

This executes create-vue, the official Vue scaffolding tool. During setup, select only Vue Router since no other features are needed for this demonstration:

Project name: … my_electron_app
✔ Add TypeScript? … No
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … No
✔ Add Vitest for Unit Testing? … No
✔ Add Cypress for both Unit and End-to-End testing? … No
✔ Add ESLint for code quality? … No
✔ Add Prettier for code formatting? … No

After project creasion, install dependencies:

cd my_electron_app
npm install
npm run dev

Output should display:

VITE v3.1.2  ready in 333 ms
  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

Accessing http://localhost:5173/ confirms successful Vue project creation.

To modify the default port, update vite.config.js:

export default defineConfig({
  server: {
    port: 3004
  }
});

Installing Electron

Install Electron as a development dependency:

npm install electron --save-dev

If installation stalls, configure the mirror:

ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"

Linux/macOS: Add the export line to ~/.zshrc or ~/.bashrc and run source ~/.zshrc.

Windows: Set via System Environment Variables in Control Panel.

Running Electron Independently

Create electron/index.html with basic content:

<html>
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <title>Desktop App Demo</title>
  </head>
  <body>
    <h1>Hello from Electron!</h1>
  </body>
</html>

Create electron/main.js:

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

function initializeWindow() {
  const desktopWindow = new BrowserWindow({
    width: 800,
    height: 600,
  })
  desktopWindow.loadFile(path.join(__dirname, "./index.html"));
}

app.whenReady().then(() => {
  initializeWindow()
})

Update package.json:

{
  "main": "electron/main.js",
  "scripts": {
    "electron:dev": "electron ."
  }
}

Running npm run electron:dev displays the Electron window.

Integrating Vue with Electron

Remove electron/index.html and update electron/main.js:

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

function initializeWindow() {
  const desktopWindow = new BrowserWindow({
    width: 800,
    height: 600,
  })
  desktopWindow.loadURL("http://localhost:3004/");
}

app.whenReady().then(() => {
  initializeWindow()
})

When using nginx to proxy requests to a production URL, update loadURL and add certificate handling:

app.on(
  "certificate-error",
  function (event, webContents, url, error, certificate, callback) {
    event.preventDefault();
    callback(true);
  }
);

Start both services: npm run dev for Vue, then npm run electron:dev for Electron.

Using concurrently for Single Command

Install the package:

npm install concurrently --save-dev

Update package.json:

{
  "scripts": {
    "electron:dev": "concurrently vite \"electron .\""
  }
}

If Vue takes longer to start and Electron shows a 502 error, add a delay:

macOS:

"electron:dev": "concurrently vite \"sleep 2 && electron .\""

Windows:

"electron:dev": "concurrently vite \"ping 127.0.0.1 -n 3 > nul && electron .\""

Cross-platform solution: Create server/dev.js:

const { execSync } = require("child_process");
const os = require("os");

function executeWithDelay(cmd1, cmd2, delay = 1) {
  let sleepCmd = "";
  if (os.platform() === "win32") {
    sleepCmd = `ping 127.0.0.1 -n ${delay + 1} > nul`;
  } else {
    sleepCmd = `sleep ${delay}`;
  }

  const combined = `concurrently ${cmd1} "${sleepCmd} && ${cmd2}"`;

  execSync(combined, {
    stdio: "inherit",
    maxBuffer: 2 * 1024 * 1024,
  });
}

executeWithDelay("vite", "electron .", 1);

Set command to "electron:dev": "node server/dev.js".

Enhanced Configuration

Producsion-ready electron/main.js:

const { app, BrowserWindow, Menu, screen } = require("electron");
const path = require("path");

const isProduction = app.isPackaged;

Menu.setApplicationMenu(null);

let primaryWindow;

function createMainWindow() {
  primaryWindow = new BrowserWindow({
    title: "Desktop Application",
    width: screen.getPrimaryDisplay().workAreaSize.width,
    height: screen.getPrimaryDisplay().workAreaSize.height,
    minWidth: 800,
    minHeight: 600,
    icon: path.resolve(__dirname, "../build/app-icon.ico"),
  });

  if (!isProduction) {
    primaryWindow.webContents.openDevTools();
  }

  primaryWindow.loadURL("https://production-domain.com/");
}

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

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

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

if (!isProduction) {
  app.on(
    "certificate-error",
    function (event, webContents, url, error, certificate, callback) {
      event.preventDefault();
      callback(true);
    }
  );
}

Hot Module Replacement Issue

When using nginx proxy, add to the nginx configuration:

server {
    location / {
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

Building the Application

Installing electron-builder

yarn add electron-builder --dev
# or
npm install electron-builder --save-dev

Configuration Steps

1. Update base path in vite.config.js:

export default defineConfig({
  base: "./"
});

2. Update Electron entry point to use built files:

const path = require("path");

function createMainWindow() {
  mainWindow.loadURL(`file://${path.join(__dirname, "../dist/index.html")}`);
}

3. Configure package.json:

{
  "name": "my-desktop-app",
  "description": "Desktop Application with Vue 3",
  "author": "Developer",
  "version": "1.0.0",
  "scripts": {
    "electron:build": "npm run build && electron-builder",
    "postinstall": "electron-builder install-app-deps"
  },
  "build": {
    "appId": "com.app.desktop",
    "productName": "My Desktop App",
    "copyright": "Copyright © 2024",
    "files": [
      "dist/**/*",
      "electron/**/*"
    ],
    "directories": {
      "output": "release"
    },
    "mac": {
      "category": "public.app-category.music",
      "icon": "build/icon.icns",
      "target": [
        { "target": "dmg", "arch": ["x64"] },
        { "target": "zip", "arch": ["x64"] }
      ]
    },
    "win": {
      "icon": "build/icon.ico",
      "target": [
        { "target": "nsis", "arch": ["x64", "ia32"] }
      ]
    },
    "nsis": {
      "oneClick": false,
      "allowToChangeInstallationDirectory": true,
      "installerIcon": "build/icon.ico",
      "uninstallerIcon": "build/icon.ico"
    }
  }
}

Building the Package

Run npm run electron:build. Output appears in the release directory.

Additional Considerations

Web and Desktop Dual Deployment

Create environment files:

.env.development:

VITE_PLATFORM = 'web'

.env.production:

VITE_PLATFORM = 'web'

.env.electron_production:

VITE_PLATFORM = 'electron'

Update build script to vite build --mode electron_production && electron-builder.

Modify vite.config.js:

import { defineConfig, loadEnv } from "vite";

export default ({ mode }) => {
  const isWeb = loadEnv(mode, process.cwd()).VITE_PLATFORM === "web";

  return defineConfig({
    base: isWeb ? "/" : "./",
  });
};

For CDN assets:

export default ({ mode }) => {
  const isWeb = loadEnv(mode, process.cwd()).VITE_PLATFORM === "web";
  const config = {};

  if (isWeb) {
    config.experimental = {
      renderBuiltUrl(filename, { type }) {
        if (type === "asset") {
          return "https://cdn.example.com/" + filename;
        }
      },
    };
  }

  return defineConfig(config);
};

Handling Page Refresh Errors

In electron/main.js:

const { app, BrowserWindow } = require("electron");

const isProduction = app.isPackaged;
let primaryWindow;

function createWindow() {
  primaryWindow = new BrowserWindow({});

  function loadPage() {
    primaryWindow.loadURL(
      isProduction
        ? `file://${path.join(__dirname, "../dist/index.html")}`
        : "https://production-domain.com/"
    );
  }

  if (isProduction) {
    primaryWindow.webContents.on("did-fail-load", () => {
      loadPage();
    });

    primaryWindow.webContents.on("will-navigate", (event, url) => {
      event.preventDefault();
      loadPage();
    });

    primaryWindow.webContents.on("before-input-event", (event, input) => {
      const isRefresh =
        input.key.toLowerCase() === "f5" ||
        (input.control && input.key.toLowerCase() === "r") ||
        (input.meta && input.key.toLowerCase() === "r");

      if (isRefresh) {
        primaryWindow.webContents.setIgnoreMenuShortcuts(true);
      }
    });
  }

  loadPage();
}

File Protocol URL Issues

Convert file:// URLs to https://:

const { app, BrowserWindow, session } = require("electron");

const isProduction = app.isPackaged;

function createWindow() {
  const mainWindow = new BrowserWindow({});

  if (isProduction) {
    session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
      if (/^file:\/\/production-domain\.com\//.test(details.url)) {
        callback({
          redirectURL: details.url.replace(/^file/, "https")
        });
      } else {
        callback(details);
      }
    });
  }
}

Cookie Handling

Create electron/cookies.js:

const { session } = require("electron");

function parseCookieDetails(cookieString) {
  const details = {
    url: "https://production-domain.com/",
  };
  const parts = cookieString.split("; ");
  parts.forEach((item, index) => {
    const [key, value] = item.split("=");
    if (index === 0) {
      details.name = key;
      details.value = value;
    } else if (["domain", "path"].includes(key)) {
      details[key] = value;
    } else if (key === "expires") {
      details.expirationDate = new Date(value).getTime() / 1000;
    } else if (key === "HttpOnly") {
      details.httpOnly = true;
    }
  });
  return details;
}

module.exports = {
  isApiRequest: /^https:\/\/production-domain\.com\/api\//,

  setCookies: function (cookies) {
    const promises = cookies.map(cookie =>
      session.defaultSession.cookies.set(parseCookieDetails(cookie))
    );
    return Promise.all(promises);
  },

  getCookies: async function () {
    const cookies = await session.defaultSession.cookies.get({
      domain: ".production-domain.com"
    });
    return cookies.map(c => `${c.name}=${c.value}`).join("; ");
  },
};

Update electron/main.js:

const { app, BrowserWindow, session } = require("electron");
const { isApiRequest, setCookies, getCookies } = require("./cookies.js");

function createWindow() {
  const mainWindow = new BrowserWindow({});

  if (app.isPackaged) {
    session.defaultSession.webRequest.onBeforeSendHeaders(
      async (details, callback) => {
        if (isApiRequest.test(details.url)) {
          const cookies = await getCookies();
          if (cookies) {
            details.requestHeaders.cookie = cookies;
          }
        }
        callback({ requestHeaders: details.requestHeaders });
      }
    );

    session.defaultSession.webRequest.onHeadersReceived(
      async (details, callback) => {
        if (
          isApiRequest.test(details.url) &&
          details.responseHeaders &&
          details.responseHeaders["set-cookie"]
        ) {
          await setCookies(details.responseHeaders["set-cookie"]);
        }
        callback({ responseHeaders: details.responseHeaders });
      }
    );
  }
}

For document.cookie in file protocol scenarios, implement a fallback using the electron cookie store directly.

Single Instance Lock

const lock = app.requestSingleInstanceLock();

if (!lock) {
  app.quit();
} else {
  app.on("second-instance", () => {
    if (mainWindow) {
      if (mainWindow.isMinimized()) {
        mainWindow.restore();
      }
      mainWindow.focus();
    }
  });

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

Exit Confirmation Dialog

const { app, BrowserWindow, dialog } = require("electron");

function createWindow() {
  const mainWindow = new BrowserWindow({});

  mainWindow.on("close", (e) => {
    e.preventDefault();
    dialog.showMessageBox(mainWindow, {
      type: "info",
      title: "Confirm Exit",
      defaultId: 0,
      cancelId: 1,
      message: "Are you sure you want to quit?",
      buttons: ["Yes", "No"],
    }).then(({ response }) => {
      if (response === 0) {
        app.exit(0);
      }
    });
  });
}

Code Signing

Windows

Configure signing algorithm in package.json:

{
  "build": {
    "win": {
      "signingHashAlgorithms": ["sha256"]
    }
  }
}

Set environment variables CSC_LINK (certificate path) and CSC_KEY_PASSWORD (certificate password), then run npm run electron:build.

macOS

Enroll in Apple Developer Program. Generate signing certificates following the official macOS Electron signing guide.

Install the notarization tool:

npm install @electron/notarize --save-dev

Generate an Apple-specific password at https://appleid.apple.com/account/manage, then store it:

security add-generic-password -a "<APPLE_ID>" -w "<PASSWORD>" -s "AC_PASSWORD"

Create build/notarize.js:

const { notarize } = require("@electron/notarize");

exports.default = async function packageTask(context) {
  const { electronPlatformName, appOutDir } = context;
  if (electronPlatformName !== "darwin") {
    return;
  }

  const appName = context.packager.appInfo.productFilename;

  return await notarize({
    appBundleId: "com.app.desktop",
    appPath: `${appOutDir}/${appName}.app`,
    appleId: "developer@example.com",
    appleIdPassword: `@keychain:AC_PASSWORD`,
  });
};

Create build/entitlements.mac.plist:

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
  <dict>
    <key>com.apple.security.cs.allow-jit</key>
    <true/>
    <key>com.apple.security.cs.debugger</key>
    <true/>
  </dict>
</plist>

Add to package.json:

{
  "build": {
    "afterSign": "build/notarize.js",
    "mac": {
      "hardenedRuntime": true,
      "gatekeeperAssess": false,
      "entitlements": "build/entitlements.mac.plist"
    }
  }
}

Set CSC_LINK and CSC_KEY_PASSWORD, then build.

Auto-Updates

Install the updater:

npm install electron-updater

Configure publish settings in package.json:

{
  "build": {
    "publish": {
      "provider": "generic",
      "url": "https://update-server.com/releases/",
      "channel": "latest"
    }
  }
}

Note: macOS requires signing and must include both dmg and zip targets.

Update electron/main.js:

const { app, BrowserWindow, dialog } = require("electron");
const { autoUpdater } = require("electron-updater");

function createWindow() {
  const mainWindow = new BrowserWindow({});

  function initializeUpdater() {
    const updateUrl = "https://update-server.com/releases/";

    autoUpdater.autoDownload = false;
    autoUpdater.autoInstallOnAppQuit = true;
    autoUpdater.allowPrerelease = false;
    autoUpdater.allowDowngrade = false;
    autoUpdater.setFeedURL(updateUrl);

    autoUpdater.on("error", () => {});
    autoUpdater.on("checking-for-update", () => {});
    autoUpdater.on("update-not-available", () => {});

    autoUpdater.on("update-available", (info) => {
      dialog.showMessageBox(mainWindow, {
        type: "info",
        title: "Update Available",
        message: "A new version is available. Install now?",
        buttons: ["Install", "Later"],
      }).then(({ response }) => {
        if (response === 0) {
          autoUpdater.downloadUpdate();
        }
      });
    });

    autoUpdater.on("download-progress", () => {});

    autoUpdater.on("update-downloaded", () => {
      dialog.showMessageBox(mainWindow, {
        type: "info",
        title: "Update Ready",
        message: "Update downloaded. Restart to apply?",
        buttons: ["Restart", "Later"],
      }).then(({ response }) => {
        if (response === 0) {
          autoUpdater.quitAndInstall();
          mainWindow.destroy();
        }
      });
    });

    autoUpdater.checkForUpdates();
  }

  if (app.isPackaged) {
    initializeUpdater();
  }
}

Build and upload release files to the update server:

macOS: latest-mac.yml, -mac.zip, .dmg

Windows: latest.yml, Setup.exe

Tags: electron Vue 3 Desktop Application Vite electron-builder

Posted on Wed, 09 Sep 2026 16:21:34 +0000 by laknal