Architecture
This page explains the package's runtime boundaries and the design decisions that shape how you extend it. Read Usage first if you just want to call the API — come here when you need to know why something is shaped the way it is, or which file to edit for a given change.
The first two sections give the big picture: the process/trust model, and why createElectronApp() is shaped the way it is. The rest are deep dives into specific cross-cutting mechanisms (IPC trust, permissions, the context menu handoff, plugins) plus reference material (CLI internals, tests) for when you're editing a particular area.
Package entry points
| Import | Source | Purpose |
|---|---|---|
@devioarts/electron |
src/index.ts → src/app.ts + src/shared/types.ts |
createElectronApp(), ElectronConfig / ElectronBridge / PluginConfigMap types |
@devioarts/electron/main |
src/main/index.ts |
Main-process building blocks, re-exported from ~30 feature modules |
@devioarts/electron/preload |
src/preload/index.ts |
createElectronBridge(), PluginManifest type |
@devioarts/electron/react |
src/react/index.ts |
React hooks |
dae (bin) |
src/cli/* |
CLI: init, run, build, kill, sync |
src/shared/ holds config.ts (all ElectronConfig sub-types) and types.ts/bridge.ts (the ElectronBridge interface, IpcResult, event payload types). Both main and preload import from here, so their contracts stay in sync at compile time.
Process and trust boundaries
contextBridge trusted IPC
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Renderer │────────────────▶│ Preload │────────────────▶│ Main process │
│ (untrusted) │ │ (isolated) │ │(Node/Electron) │
└────────────────┘ └────────────────┘ └────────────────┘
The renderer never touches Node or Electron APIs directly — contextBridge and contextIsolation block that. It can only call what createElectronBridge() exposes on window.Electron, and every one of those calls crosses into main as an IPC message that main decides whether to trust. See IPC trust model below for how that check works.
Why createElectronApp() isn't a single black box
Electron's own lifecycle is order-sensitive: privileged custom schemes must be registered before app.whenReady(), while CSP, tray, and menu setup must happen inside whenReady(). electron/main.ts (scaffolded by dae init) explicitly imports building blocks from src/main/index.ts and calls them in that specific order, rather than hiding the ordering behind one opaque function.
createElectronApp(config, options) encodes that ordering for you. It accepts the same building blocks as its second argument:
createElectronApp(config, {
appMenu, contextMenu, dockMenu, trayMenu, jumpList, shortcuts, onReady,
});
For full manual control, skip createElectronApp() entirely and call setupTray, setupMenu, trustedIpcHandle, getMainWindow, etc. directly from a hand-written electron/main.ts.
One file per feature area
src/main/ has one module per renderer-facing capability, named after it — clipboard.ts owns every clipboard:* channel, notifications.ts every notifications:* channel, and so on, matching the groups in usage.md's API reference one-to-one.
To change how a capability behaves, edit its matching file. To add a new one, follow the pattern in an existing module rather than growing one further. The rest of this page covers the modules with cross-cutting responsibilities — the ones not tied to a single bridge group.
IPC trust model
src/main/ipc-trust.ts closes the gap contextBridge and contextIsolation leave open: they stop the renderer from touching Node or Electron directly, but ipcMain.handle() still answers any frame that sends on a known channel name — and channel names aren't secret, they're in this package's source. If the app ever loads content it doesn't fully trust (a managed external-URL window, a misbehaving redirect, a future iframe), that content could otherwise reach privileged handlers like secureStorage or externalCommands just by knowing the channel string.
setIpcSenderCheck(fn: (url: string) => boolean) closes that gap. createElectronApp() calls it once the app's real URL is known, with a check covering every supported load mode:
function isTrustedOrigin(url: string): boolean {
if (isDev && url.startsWith(devUrl)) return true;
if (config.app?.loadMode === 'protocol') return url.startsWith(appProtocolUrl(protocolConfig, '/'));
if (config.app?.loadMode === 'server') return serverOrigin !== null && url.startsWith(serverOrigin);
return url.startsWith('file://');
}
setIpcSenderCheck(isTrustedOrigin);
Before that call happens — for example while handlers are still registering during startup — "no check installed" is treated as trust-everything rather than trust-nothing, since there's nothing meaningful to compare against yet.
Every window.Electron.* channel is registered through one of:
trustedIpcHandle(channel, listener)— the sender check runs, then the listener's return/throw goes straight toipcMain.handle's normal behavior. Use for trivial getters/setters that can't meaningfully fail.trustedIpcHandleSafe(channel, listener)— same check, but every outcome is wrapped asIpcResult<T>:{ ok: true, data }or{ ok: false, error: { code, message } }, including a failed trust check (code: 'FORBIDDEN'). Renderer callers never need try/catch, justif (!result.ok). Use for handlers whose failure is a normal, expected outcome (bad input, missing config, a process that failed to start).trustedIpcOn(channel, listener)/trustedIpcOnSync(channel, listener)— fire-and-forget and synchronous counterparts, for channels the renderer doesn't await a reply on (for example, reporting where the last right-click landed, before Electron's owncontext-menuevent fires).
IpcError (thrown by handlers) carries a machine-readable code: VALIDATION, NOT_CONFIGURED, RUNTIME, UNSUPPORTED_OS, or (implicitly) FORBIDDEN/UNKNOWN. Helper factories: validationError(), notConfiguredError(), runtimeError(), unsupportedOsError().
ipc-trust.ts also owns a small shared "what's the current main window" registry (setMainWindow, getMainWindow, onMainWindowChanged). Nearly every other module — tray, shortcuts, deep-link, windows — needs this, so it rides along here rather than adding a fourth shared module for one concern.
Permission and navigation hardening
src/main/permissions.ts overrides two of Electron's defaults unconditionally — silently allowing every permission request, and letting any window navigate or open popups anywhere:
- Permission requests are checked against
security.allowedPermissions(default[], i.e. deny all). - The main window is prevented from navigating away from its own origin, and
window.open/target="_blank"are denied outright (windows.create()is the sanctioned replacement).
Context menu two-step handoff
createElectronBridge() (preload) attaches a capture-phase contextmenu listener on window. On each click it walks the event's composed path to find the most semantically meaningful target — an element with an id, data-* attributes, or a class name matching /context|menu/i — and records { x, y, target }. It ships that target to main synchronously:
ipcRenderer.sendSync('menu:contextTargetSync', { x, y, target });
Because that call is synchronous, main already has the target stored by the time Electron's own native context-menu event fires on the same tick. This runs unconditionally, even if the app never enables ui.contextMenu — it's cheap to send, and main-process code ignores it when nothing's listening.
Plugins
src/main/plugins.ts and src/preload/plugins.ts extend the same trust model to plugin code. registerPlugin() gives a plugin the same IpcResult contract and trust check as core features:
registerPlugin(name, instance, methods, events?);
// each method becomes a channel: plugin:<name>:<method>, via trustedIpcHandleSafe()
buildPluginBridges(manifests) (preload side) turns PluginManifest[] into the renderer-facing window.Electron.<Name> objects. See Plugins for the authoring/installation workflow.
CLI internals
src/cli/*:
index.ts— argv dispatch (init/add,run,build,kill,sync) and help text.init.ts— copiestemplates/*into the project, skipping files that already exist.config-loader.ts— loads and validateselectron.config.ts.run.ts— dev workflow: resolves the dev URL (including'auto'port detection viavite.config'sserver.strictPort), bundleselectron/main.ts+preload.tswith esbuild, launches Electron, watches for changes and restarts.build.ts— runs the renderer build, bundles Electron sources, invokeselectron-builderwithelectron-builder.config.tsplusappId/appNamefromelectron.config.ts.kill.ts— terminates stray Electron/Node processes for the project.sync.ts— regenerateselectron/plugins/generated/*from installed plugin packages carrying the"dae": { "plugin": true }marker.paths.ts,pm.ts— path resolution and process-management helpers shared by the above.
Testing
tests/unit/*.test.ts— one file persrc/main(and CLI) module, using the Electron mock intests/__mocks__/electron.tsand the updater mock intests/__mocks__/electron-updater.ts.tests/integration/cli-lifecycle.test.ts— exercises the CLI end-to-end (requires a build first —pretest:integrationrunsnpm run build).tests/e2e/*— Playwright tests (app.spec.ts) against the built app, withprepare.ts/global-setup.tshandling setup.
Root package.json scripts: npm test / test:unit (unit only), test:integration, test:all (unit + integration), test:watch, test:e2e (Playwright, via pretest:e2e → tsx tests/e2e/prepare.ts).