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'spackage.json,npm installit, then runnpx dae sync. It scans declared dependencies for the marker and regenerateselectron/plugins/generated/{main,preload}.ts— don't hand-edit these; re-rundae syncafter 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 toelectron/plugins/user/{main,preload}.ts(scaffolded bydae init, never touched bydae 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/* wiring — createElectronApp() 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.