Capacitor platform to build, run, and package desktop apps for macOS, Windows, and Linux with Electron.1
The Capacitor Electron platform brings your web app and your Capacitor plugins to the desktop. Here are some of the key features:
- 🖥️ Cross-platform: Build desktop apps for macOS, Windows, and Linux from one codebase.
- ⚡ Familiar workflow:
cap add,cap sync,cap run— the same commands as iOS and Android. - 🔌 Plugin support: Capacitor plugins with an Electron implementation work out of the box; plugins with a web implementation work automatically via fallback.
- 🔒 Security-first: Sandboxed renderer, context isolation, strict Content-Security-Policy, and validated IPC — enabled by default and not configurable.
- 🔗 Deep links: Custom URL schemes delivered through the standard
@capacitor/appplugin'sappUrlOpenevent. - 📱 App lifecycle:
appStateChange,pause, andresumeevents, exactly like on mobile. - ♻️ Live reload: Develop against your web dev server with full HMR.
- 📦 Packaging: Create installers with electron-builder, including automatic dependency vendoring.
- 🪟 Window management: Typed window options, state persistence, and hooks for trays and menus.
- 🧩 Minimal scaffold: You own a handful of small, stable files; all platform logic ships in the package and updates via
npm update. - 🔁 Up-to-date: Always supports the latest Capacitor and Electron versions.
- ⭐️ Support: Priority support from the Capawesome Team.
- ✨ Handcrafted: Built from the ground up with care and expertise, not forked or AI-generated.
Missing a feature? Just open an issue and we'll take a look!
The Electron platform is typically used to bring an existing Capacitor app to the desktop, for example:
- Desktop companion apps: Ship your mobile app's functionality to macOS, Windows, and Linux without a rewrite.
- Offline-first desktop tools: Combine the platform with plugins like SQLite for fully offline desktop applications.
- Internal business tools: Distribute apps directly to your team without app stores.
- Kiosk and point-of-sale apps: Run your web app full screen on dedicated desktop hardware.
- Deep-link driven workflows: Handle custom URL schemes on the desktop exactly like on mobile.
| Platform Version | Capacitor Version | Electron Version | Status |
|---|---|---|---|
| 0.x | >=6.x.x | >=28.x.x | Active support |
Note
On Capacitor 6 and 7, the Capacitor CLI ignores the exit code of platform hooks, so a failing npx cap sync still reports success — check the log output for [capacitor-electron] errors. Capacitor 8 fails the command properly.
Plugins integrate with the Electron platform in one of three ways:
| Plugin | Support |
|---|---|
@capacitor/app |
✅ Built into the platform (appUrlOpen, appStateChange, pause, resume, getInfo, getState, getLaunchUrl, exitApp, minimizeApp) |
@capawesome-team/capacitor-sqlite |
✅ Native Electron implementation via node:sqlite |
| Plugins with a web implementation | ✅ Automatic web fallback |
Plugins that require native functionality beyond their web implementation need a dedicated Electron implementation (see Plugin Development). Is your favorite plugin missing? Just open an issue and we'll take a look!
You can use our AI-Assisted Setup to add the platform. Add the Capawesome Skills to your AI tool using the following command:
npx skills add capawesome-team/skills --skill capacitor-platformsThen use the following prompt:
Use the `capacitor-platforms` skill from `capawesome-team/skills` to add the `@capawesome/capacitor-electron` platform to my project.
If you prefer Manual Setup, add the platform by running the following commands:
npm install @capawesome/capacitor-electron
npx cap add @capawesome/capacitor-electron
cd electron && npm install && cd ..We recommend adding a postinstall script to your root package.json so the Electron dependencies are always installed together with your app dependencies:
{
"scripts": {
"postinstall": "cd electron && npm ci && cd .."
}
}The initial cd electron && npm install generates electron/package-lock.json — commit it so that npm ci works for every future install.
Note
Always use the full package name with Capacitor CLI commands (e.g. npx cap sync @capawesome/capacitor-electron). A bare npx cap sync electron resolves to the electron npm package and silently does nothing.
The scaffolded electron/ project contains only files you own:
| File | Purpose |
|---|---|
main.ts |
~5 lines: imports the runtime and starts the app |
capacitor.electron.config.ts |
Typed platform options (window, CSP, deep links, hooks) |
electron-builder.config.js |
Packaging configuration |
assets/ |
App icons |
Everything with logic lives in the versioned npm package and updates via npm update — your platform project never rots.
Platform options are configured in electron/capacitor.electron.config.ts with full type safety:
import { defineConfig } from '@capawesome/capacitor-electron/config';
export default defineConfig({
window: {
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
},
deepLinks: {
scheme: 'myapp',
},
});Extension happens through typed options and hooks (windowFactory, beforeReady, onWindowCreated, CSP overrides) — never by owning runtime code.
Some plugins support platform-specific configuration on Android and iOS — for example, the Capacitor Live Update plugin reads a default channel from an Android string resource or an iOS Info.plist key so the build system can inject a value derived from the app version. The plugins section of electron/capacitor.electron.config.ts fills that role on Electron — generically, for every plugin. Because the file is executable TypeScript evaluated in the main process, values can be computed in code — no template syntax required.
It is merged over the plugins section of the Capacitor config shallowly, per plugin key, and this section wins: for each plugin, its keys override the matching keys in the Capacitor config while unmentioned keys survive; plugins present in only one of the two configs pass through unchanged. Everything outside plugins is untouched. The merge runs once at startup, before plugins receive their config.
import { defineConfig } from '@capawesome/capacitor-electron/config';
import packageJson from './package.json';
export default defineConfig({
plugins: {
LiveUpdate: {
defaultChannel: `production-${packageJson.version}`,
},
},
});Importing
./package.jsonrequiresresolveJsonModuleinelectron/tsconfig.json(already enabled in the scaffold).
A working example can be found here: capawesome-team/capacitor-electron
# Copy web assets + regenerate the plugin manifest
npx cap sync @capawesome/capacitor-electron
# Run the app (uses server.url for live reload when configured)
npx cap run @capawesome/capacitor-electron
# Open the built app
npx cap open @capawesome/capacitor-electronSet server.url in your Capacitor config to your dev server and run the app — the window loads the dev server with full HMR, and the plugin bridge works exactly as in production:
// capacitor.config.ts
const config: CapacitorConfig = {
// ...
server: {
url: 'http://localhost:5173',
},
};npx vite & # your web dev server
npx cap run @capawesome/capacitor-electron # dev modeIn dev mode a documented, dev-only CSP relaxation is applied (inline scripts, eval, websockets — required by HMR runtimes), and the window automatically reconnects when the dev server restarts. Remove server.url (or use a production config) to serve the built web assets from the app bundle again.
Declare the custom URL scheme in the platform configuration (see Configuration) and listen to the standard @capacitor/app event:
import { App } from '@capacitor/app';
await App.addListener('appUrlOpen', ({ url }) => {
console.log('App opened with URL:', url);
});Deep links opened while the app is running are routed to the running instance (single instance is enforced by default); the URL that launched the app is available via App.getLaunchUrl().
Booting a desktop app is not instant: the platform runs every plugin's load() lifecycle hook (e.g. the Live Update plugin verifying and activating a bundle) before the main window is shown. A splash screen covers that gap so the app never appears frozen or blank.
A splash screen is shown automatically when a splash file exists in the electron app directory — no configuration required. Two files are looked up, in order:
electron/assets/splash.htmlelectron/assets/splash.png
The scaffold ships a neutral, theme-aware assets/splash.html by default. Migrating from @capacitor-community/electron? Its assets/splash.png is picked up unchanged.
Configure the splash screen in electron/capacitor.electron.config.ts:
import { defineConfig } from '@capawesome/capacitor-electron/config';
export default defineConfig({
splashScreen: {
// Custom file, relative to the electron app directory. Either an `.html`
// file or an image (`.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`).
path: 'assets/splash.html',
width: 400,
height: 300,
backgroundColor: '#ffffff',
// Keep the splash visible for at least this long, even on fast startups.
minimumDurationMs: 0,
},
});| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
shown when a splash file exists | Set false to disable. Set true to require a splash file — boot fails if none is found. |
path |
string |
assets/splash.html, assets/splash.png |
Splash file relative to the electron app directory. |
width |
number |
400 |
Window width in pixels. |
height |
number |
300 |
Window height in pixels. |
backgroundColor |
string |
'#ffffff' |
Window background and the canvas behind an image splash. |
minimumDurationMs |
number |
0 |
Minimum time the splash stays visible. |
HTML vs. image: an .html file is loaded directly, so you get full control over layout, fonts, and animation. An image is centered (object-fit: contain) on a backgroundColor canvas — convenient for a logo, but static.
The splash window is deliberately kept outside the plugin bridge: it is frameless, sandboxed, has no preload and no access to the app scheme, and navigation is blocked.
Packaging note: the splash files live under
assets/, which is also the electron-builderbuildResourcesdirectory. The scaffoldedelectron-builder.config.jsincludesassets/**/*infilesso the splash ships inside the packaged app (app.asar). If you replace the config, keep that entry — otherwise the splash works in development but silently disappears from packaged binaries.
The platform keeps Electron's default application menu, so the Chromium DevTools can be opened at any time via View → Toggle Developer Tools or the keyboard shortcut:
| Operating System | Shortcut |
|---|---|
| macOS | Cmd + Option + I |
| Windows | Ctrl + Shift + I |
| Linux | Ctrl + Shift + I |
To open the DevTools automatically on launch (e.g. to catch logs from early app startup), use the onWindowCreated hook in electron/capacitor.electron.config.ts:
import { defineConfig } from '@capawesome/capacitor-electron/config';
export default defineConfig({
// ...
hooks: {
onWindowCreated: window => {
window.webContents.openDevTools();
},
},
});Use the onWindowCreated hook together with Electron's Tray and Menu APIs to add a tray icon and context menu:
import { defineConfig } from '@capawesome/capacitor-electron/config';
import { app, Menu, Tray } from 'electron';
import { join } from 'path';
export default defineConfig({
// ...
hooks: {
onWindowCreated: window => {
const tray = new Tray(join(app.getAppPath(), 'assets', 'tray-icon.png'));
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show', click: () => window.show() },
{ label: 'Hide', click: () => window.hide() },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
]),
);
},
},
});Place the tray icon under electron/assets/, which already ships inside the packaged app (see the Splash Screen packaging note). To start the app minimized to the tray, set window.showOnLaunch: false so the main window is created hidden until the user picks Show.
Migrating from
@capacitor-community/electron? This recipe replaces itstrayIconAndMenuEnabledandhideMainWindowOnLaunchoptions — build the tray via the hook above and usewindow.showOnLaunch: falsefor start-in-tray behavior.
You can use our AI-Assisted Migration to migrate from @capacitor-community/electron.
Add the Capawesome Skills to your AI tool using the following command:
npx skills add capawesome-team/skills --skill capacitor-platformsThen use the following prompt:
Use the `capacitor-platforms` skill from `capawesome-team/skills` to migrate my project from `@capacitor-community/electron` to `@capawesome/capacitor-electron`.
If you prefer Manual Migration, perform the following steps:
- Back up anything you customized in your existing
electron/directory (icons, electron-builder configuration, and any code you added to the generated runtime files). - Remove the old platform and its
electron/directory:npm uninstall @capacitor-community/electron rm -rf electron
- Add this platform:
npm install @capawesome/capacitor-electron npx cap add @capawesome/capacitor-electron cd electron && npm install && cd ..
- Re-apply your customizations through the typed options in
electron/capacitor.electron.config.ts(window options, deep-link scheme, CSP overrides, tray/menu via hooks) instead of editing runtime code, and restore your icons toelectron/assets/and your electron-builder settings inelectron/electron-builder.config.js. - Sync and run (always with the full package name):
npx cap sync @capawesome/capacitor-electron npx cap run @capawesome/capacitor-electron
Notes:
- Deep links no longer require hand-written runtime code — declare the scheme in the platform config and listen to
@capacitor/app'sappUrlOpenevent. - Splash screens are picked up automatically from
electron/assets/. Keepassets/splash.pngand it just works; if you used a customsplashScreenImageName: 'x.gif', either rename it toassets/splash.pngor point the config at it viasplashScreen: { path: 'assets/x.gif' }(see Splash Screen). - Plugins must provide an electron implementation for this platform's contract (see Plugin Development); implementations written for the old platform are not loaded. Plugins whose web implementation is sufficient continue to work unchanged via the automatic fallback.
Plugins declare their electron implementation via package.json:
{
"capacitor": {
"electron": { "src": "electron" }
}
}The implementation is an ES module at <src>/dist/plugin.mjs exporting plugin classes. A plugin class declares its Capacitor registration name and its public API via static metadata — the static property is the contract, so a build-time dependency on this package is not required.
Mirroring how Android/iOS plugins extend Capacitor's Plugin and override load(), the recommended path is to extend the ElectronPlugin base class. Add @capawesome/capacitor-electron as a devDependency (for the types) and an optional peerDependency (for the runtime value), then:
import { ElectronPlugin, defineElectronPlugin } from '@capawesome/capacitor-electron/plugin';
class SqliteImpl extends ElectronPlugin {
// `this.context` (config, services, notifyListeners) is stored by the base constructor.
// Optional lifecycle hook. Awaited by the platform before the first window loads.
async load() { ... }
async open(options) { ... }
async query(options) { ... }
// Not declared below, therefore never bridged.
resolvePath(path) { ... }
}
export const Sqlite = defineElectronPlugin(
{ name: 'Sqlite', methods: ['open', 'query'] },
SqliteImpl,
);The base class is optional sugar — the discovery contract is the static __capacitorElectronPlugin metadata, and the lifecycle hook is detected structurally (never via instanceof, which would break across duplicated copies of this package). So a plugin can ship with no dependency on this package at all, implementing a structural load() if it needs the hook:
class SqliteImpl {
constructor({ config, services, notifyListeners }) { ... }
// Optional. Structural lifecycle hook, detected by name.
async load() { ... }
async open(options) { ... }
async query(options) { ... }
// Not declared below, therefore never bridged.
resolvePath(path) { ... }
}
// Equivalent to defineElectronPlugin, without importing this package.
SqliteImpl.__capacitorElectronPlugin = {
name: 'Sqlite',
methods: ['open', 'query'],
};
export { SqliteImpl as Sqlite };The declared methods array is the plugin's entire bridged surface: anything not listed stays main-process-internal, and a declared method that is missing on the class fails loudly at boot. Each class is instantiated once in the main process (full Node and Electron API access) and exposed under its registration name through Capacitor's native plugin path — registerPlugin('Sqlite', { web: ... }) just works, with the web implementation as the automatic fallback for platforms the plugin doesn't cover. No electron key in the plugin's registerPlugin wiring is needed.
The constructor context (this.context on an ElectronPlugin subclass, or the constructor argument otherwise) provides:
config— the app's Capacitor configuration.notifyListeners(eventName, data)— emits a plugin event, mirroring Capacitor's nativenotifyListeners. Web listeners use the standardaddListener(eventName, callback)/PluginListenerHandleAPI.services— platform primitives (currentlyservices.bundles: web-bundle serving, reload, and the failed-boot rollback watchdog).
load() runs once after the plugin is constructed and is awaited before the first application window loads, so async setup — including repointing the active bundle via services.bundles.setActiveBundle() — takes effect on first paint (no default-bundle flash). It is a lifecycle hook, not a bridged method: load is reserved and is never bridged to the renderer. It must not be listed in methods — doing so is rejected at boot, because bridging it would let web content invoke the lifecycle hook arbitrarily. A rejected or thrown load() fails the app boot loudly, the same way a declared-but-missing method does. On an ElectronPlugin subclass the default load() is a no-op, so overriding it is optional.
At sync time the platform statically scans the app's dependencies and generates a plugin manifest — no plugin code runs outside Electron. Results, thrown Errors, and their code properties cross the bridge with Capacitor semantics.
The scaffolded electron/ project packages with electron-builder:
cd electron && npm run packThe pack script runs three steps: compile (tsc), vendor, and electron-builder. The vendor step (capacitor-electron vendor) copies the platform runtime, every plugin's electron implementation, and their dependency closure (production, peer, and installed optional dependencies) into electron/vendor/, which the scaffolded electron-builder config maps to node_modules inside the packaged app — so module resolution works identically in development (from your app root) and in the package, without a second npm install and without version drift.
Code signing, notarization, targets (dmg/msi/nsis/AppImage/deb), and icons are standard electron-builder configuration in the user-owned electron-builder.config.js — see https://www.electron.build. Electron Forge is a supported alternative: run capacitor-electron vendor before packaging and include vendor/node_modules as the app's node_modules.
Two independent update layers, matching mobile:
- Binary updates (Electron itself, the runtime, native modules): use electron-updater — the desktop analog of an app-store update. Wire it in your
main.ts; it operates on the packaged artifacts produced above. - Web-bundle updates: the platform ships the serving primitive only —
services.bundles(activate a bundle directory, reload, boot-ready signal, and a failed-boot rollback watchdog that reverts to the previous bundle if the renderer doesn't confirm startup). The OTA update product on top of it (download, channels, verification) is deliberately not part of the platform.
- Floor: Electron 28 (required for the runtime's serving APIs). No ceiling — the scaffold uses a caret range you control, and the platform releases only when Electron actually breaks an API it uses.
- Tested: CI runs the example app against the latest stable Electron and the floor on every release.
- Electron ships a new major roughly every 8 weeks. This platform's wide peer range means you can adopt new Electron majors immediately, without waiting for a platform release.
Read this before committing to the platform — these are inherent trade-offs, not bugs:
- Binary size and memory. Every app ships its own Chromium and Node: expect roughly 80–150 MB installed and a matching memory footprint. If minimal footprint is the priority, Electron is the wrong tool.
- Plugins need an electron implementation. Only Capacitor plugins that ship an
electronimplementation (or whose web implementation is sufficient — the automatic fallback) work. iOS/Android native code does not translate. - Native Node addons are not rebuilt automatically. If a plugin's electron implementation depends on native Node modules,
capacitor-electron vendordetects and reports them, but you must rebuild them against Electron's ABI yourself (e.g.@electron/rebuild) before packaging. - No web-bundle OTA product included. The platform provides the serving primitive (
services.bundles) only; update delivery, channels, and verification are a separate product layer. - Always pass the platform name to the Capacitor CLI. A bare
npx cap synconly processes iOS/Android/web;npx cap sync electronresolves to theelectronnpm package and silently does nothing. Use the full package name (see the note under Installation). - Desktop is not mobile. APIs like geolocation permission prompts, status bars, or app-store review flows have no desktop equivalent; plugins whose web implementation assumes a mobile browser may behave differently on desktop Chromium.
Plugins with a dedicated Electron implementation or a sufficient web implementation work (see Supported Plugins). Plugins that only ship iOS/Android native code do not.
Update the electron version in electron/package.json and run npm install there. The platform supports Electron 28 and later with no upper bound.
Yes. Capacitor.getPlatform() returns 'electron' and Capacitor.isNativePlatform() returns true, so you can branch platform-specific code the same way as on iOS and Android.
- Capacitor SQLite plugin — local SQL database with a native Electron implementation.
Stay up to date with the latest news and updates about the Capawesome, Capacitor, and Ionic ecosystem by subscribing to our Capawesome Newsletter.
See CHANGELOG.md.
See LICENSE.
Footnotes
-
This project is not affiliated with, endorsed by, sponsored by, or approved by the OpenJS Foundation or any of its affiliates.
Electronis a trademark of the OpenJS Foundation. ↩