Docs

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.tssrc/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 to ipcMain.handle's normal behavior. Use for trivial getters/setters that can't meaningfully fail.
  • trustedIpcHandleSafe(channel, listener) — same check, but every outcome is wrapped as IpcResult<T>: { ok: true, data } or { ok: false, error: { code, message } }, including a failed trust check (code: 'FORBIDDEN'). Renderer callers never need try/catch, just if (!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 own context-menu event 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 — copies templates/* into the project, skipping files that already exist.
  • config-loader.ts — loads and validates electron.config.ts.
  • run.ts — dev workflow: resolves the dev URL (including 'auto' port detection via vite.config's server.strictPort), bundles electron/main.ts+preload.ts with esbuild, launches Electron, watches for changes and restarts.
  • build.ts — runs the renderer build, bundles Electron sources, invokes electron-builder with electron-builder.config.ts plus appId/appName from electron.config.ts.
  • kill.ts — terminates stray Electron/Node processes for the project.
  • sync.ts — regenerates electron/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 per src/main (and CLI) module, using the Electron mock in tests/__mocks__/electron.ts and the updater mock in tests/__mocks__/electron-updater.ts.
  • tests/integration/cli-lifecycle.test.ts — exercises the CLI end-to-end (requires a build first — pretest:integration runs npm run build).
  • tests/e2e/* — Playwright tests (app.spec.ts) against the built app, with prepare.ts/global-setup.ts handling setup.

Root package.json scripts: npm test / test:unit (unit only), test:integration, test:all (unit + integration), test:watch, test:e2e (Playwright, via pretest:e2etsx tests/e2e/prepare.ts).

Last updated on July 17, 2026