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.