Required Packages
- Vite: https://vitejs.cn
- Vue: https://cn.vuejs.org
- Electron: https://www.electronjs.org/
- electron-builder: https://github.com/electron-userland/electron-builder
- concurrently: https://github.com/open-cli-tools/concurrently
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