Docs

Plugins

Capabilities that don't belong in the base API (filesystem access, a hardware integration, anything app- or domain-specific) are added as plugins instead of growing the core package. A plugin is a package (or a local project file) with a main-process half and a preload-side manifest, wired in exactly the same explicit way every built-in feature is — no runtime plugin loader, no magic.

Writing a plugin

Main process (pkg/main.ts) — default-exports a function that registers the plugin's methods with registerPlugin():

import { registerPlugin } from '@devioarts/electron/main';
import type { ElectronConfig } from '@devioarts/electron';

class Filesystem {
  async readFile(root: string, path: string) {
    /* ... */
  }
  async writeFile(root: string, path: string, data: string) {
    /* ... */
  }
}

export default function registerFilesystemPlugin(config: ElectronConfig): void {
  registerPlugin('Filesystem', new Filesystem(), ['readFile', 'writeFile']);
}

registerPlugin(name, instance, methods, events?) registers each method as a plugin:<name>:<method> channel through the same trustedIpcHandleSafe() every core channel uses — callers get the same IpcResult envelope (check result.ok, no try/catch) and sender-trust check for free. Method arguments are forwarded positionally, not as a single options object, matching this package's own bridge style (clipboard.writeText(text), not clipboard.writeText({ text })).

For push-style events, pass an events map and use the emit() function registerPlugin() returns — hooks fire only on the first-subscriber / last-unsubscriber transition, so e.g. a file watcher only runs while someone's actually listening:

const emit = registerPlugin('Watcher', instance, ['start', 'stop'], {
  changed: {
    onAdd: () => watcher.start(),
    onRemove: () => watcher.stop(),
  },
});
watcher.on('change', (path) => emit('changed', { path }));

Preload (pkg/preload.ts) — default-exports a plain manifest. No Node or Electron imports here, so it's always safe to bundle into the renderer even though the main half touches the filesystem:

import type { PluginManifest } from '@devioarts/electron/preload';

export default {
  name: 'Filesystem',
  methods: ['readFile', 'writeFile'],
} satisfies PluginManifest;

Add events: ['changed'] to the manifest to also get a window.Electron.Filesystem.on(eventType, callback) subscription method for free.

Config — a plugin that needs its own config declares it once via TypeScript declaration merging:

// pkg/types.ts
declare module '@devioarts/electron' {
  interface PluginConfigMap {
    filesystem: { roots?: Record<string, string> };
  }
}

The app triggers the merge by importing that module once from its own electron-env.d.ts (the same step used to extend ElectronBridge, see below). config.plugins?.filesystem is then fully typed inside registerFilesystemPlugin(config) — no cast needed.

Types — to make window.Electron.Filesystem typed in the app, the plugin extends ElectronBridge:

declare module '@devioarts/electron' {
  interface ElectronBridge {
    Filesystem: {
      readFile(root: string, path: string): Promise<IpcResult<string>>;
      writeFile(root: string, path: string, data: string): Promise<IpcResult<void>>;
    };
  }
}

Installing a plugin

Two ways a plugin ends up wired into electron/main.ts / electron/preload.ts:

  • Auto-detected — add "dae": { "plugin": true } to the plugin package's package.json, npm install it, then run npx dae sync. It scans declared dependencies for the marker and regenerates electron/plugins/generated/{main,preload}.ts — don't hand-edit these; re-run dae sync after installing or removing a plugin.
  • Hand-registered — anything without the marker (a third-party package that doesn't carry it, or a plugin file local to your project). Add the same registerPlugin() call and manifest by hand to electron/plugins/user/{main,preload}.ts (scaffolded by dae init, never touched by dae sync).

Both are merged and passed into createElectronApp / createElectronBridge by the scaffolded electron/main.ts / electron/preload.ts — nothing else to wire up.

Built-in plugins

Five plugins ship inside @devioarts/electron itself, under src/plugins/ — separate from the core window.Electron.* API, but built with the exact same registerPlugin() mechanism (and plugin:<Name>:<method> channel naming) a third-party plugin uses. Unlike a third-party plugin, they need no import, no manifest, and no electron/plugins/user/* wiringcreateElectronApp() calls their registerXPlugin(config) functions unconditionally, so they're always registered and fully typed on window.Electron out of the box. Each one stays inert (every method rejects with NOT_CONFIGURED, or resolves { ok: false, error: { code: 'NOT_CONFIGURED' } } via .safe) until you flip it on in electron.config.ts — the same "always registered, opt-in via config" idiom autoUpdater and crashReporter use.

Plugin Bridge Config key Source
Filesystem window.Electron.Filesystem plugins.filesystem src/plugins/filesystem/
Preferences window.Electron.Preferences plugins.preferences src/plugins/preferences/
Network window.Electron.Network plugins.network src/plugins/network/
File Viewer window.Electron.FileViewer plugins.fileViewer src/plugins/file-viewer/
Privacy Screen window.Electron.PrivacyScreen plugins.privacyScreen src/plugins/privacy-screen/

Enable the ones you need in electron.config.ts:

plugins: {
  filesystem: { enabled: true, roots: { assets: 'assets' } },
  preferences: { enabled: true },
  network: { enabled: true },
  fileViewer: { enabled: true },
  privacyScreen: { enabled: true },
},

If you're on the "full manual control" path instead of createElectronApp()/createElectronBridge(), the five registerXPlugin() functions (registerFilesystemPlugin, registerPreferencesPlugin, registerNetworkPlugin, registerFileViewerPlugin, registerPrivacyScreenPlugin) are exported from @devioarts/electron/main alongside every other setup* building block — call each with config inside whenReady(), after setIpcSenderCheck().

Filesystem (window.Electron.Filesystem)

fs/promises scoped to named directories — every path is resolved under its root and rejected if it would escape it. Directory names: the built-in FsDirectory values 'documents' | 'data' | 'cache' | 'temp' | 'downloads' | 'desktop' | 'home', plus any custom roots declared in plugins.filesystem.roots (e.g. { assets: 'assets' }).

readFile(directory, path, encoding?) · writeFile(directory, path, data, options?) · appendFile(directory, path, data, encoding?) · deleteFile(directory, path) · mkdir(directory, path, recursive?) · rmdir(directory, path, recursive?) · readdir(directory, path?) · stat(directory, path) · rename(directory, from, to) · copy(directory, from, to) · getUri(directory, path)

encoding is 'utf8' (default) or 'base64', for both read and write. writeFile's options.recursive creates missing parent directories first (default false).

Preferences (window.Electron.Preferences)

A plain (unencrypted) JSON key/value store — the intentionally-unencrypted sibling to secureStorage. Backed by a JSON file at {userData}/plugin-data/<fileName>, configurable via plugins.preferences.fileName (default 'preferences.json').

get(key) · set(key, value) · remove(key) · clear() · keys()

Network (window.Electron.Network)

getStatus() and a polling change event over Electron's net.isOnline(). connectionType is always 'unknown' when online or 'none' when offline — Electron can't classify wifi vs cellular on desktop. Poll interval while at least one change subscriber is active: plugins.network.pollIntervalMs (default 10000).

getStatus() · on('change', callback)

File Viewer (window.Electron.FileViewer)

openDocumentFromLocalPath(path) (opens with the OS default app via shell.openPath()) · openDocumentFromUrl(url) (http/https only, via shell.openExternal()) · showItemInFolder(path) (reveal in Finder/Explorer)

Privacy Screen (window.Electron.PrivacyScreen)

enable() · disable() · isEnabled() — maps to BrowserWindow.setContentProtection() on every open window and any window opened afterwards. plugins.privacyScreen.enabled only unlocks these three methods; enable()/disable() is the separate runtime on/off switch.

Last updated on July 18, 2026