skills/recrsn/claude-plugins/electron-to-electrobun

electron-to-electrobun

SKILL.md

Electron → Electrobun Migration

Two phases: compatibility check, then migration. For mechanical transformations (import rewrites, IPC channel renames, API mappings), prefer JS/TS codemods using jscodeshift or ts-morph over manual edits.

Many Electrobun APIs are identical to Electron: BrowserWindow options (title, titleBarStyle, transparent), methods (setTitle, close, focus, minimize/isMinimized, maximize/unmaximize/isMaximized, setFullScreen/isFullScreen, setAlwaysOnTop/isAlwaysOnTop, setPosition, setSize), events (resize, focus), menu props (role, label, type, enabled, checked, submenu), GlobalShortcut, Screen, Session/Cookies. Tables below only list differences. Caveat: getPosition(){x,y} not [x,y]; getSize(){width,height} not [w,h].

Quick reference

Electron Electrobun
Node.js (V8) Bun (JavaScriptCore)
Bundled Chromium System WebView (WebKit/WebView2/WebKitGTK) or CEF
ipcMain.handle/ipcRenderer.invoke BrowserView.defineRPC<S>()/Electroview.defineRPC<S>()
webContents.send/ipcRenderer.on RPC messages (fire-and-forget)
preload + contextBridge Typed RPC (preload supported, not for IPC bridging)
dialog.showOpenDialog() Utils.openFileDialog()
dialog.showMessageBox() Utils.showMessageBox()
Menu.buildFromTemplate() ApplicationMenu.setApplicationMenu([...])
Menu.popup() ContextMenu.showContextMenu([...])
clipboard.* Utils.clipboard*()
new Notification() Utils.showNotification({title,body,subtitle?,silent?})
app.getPath(name) Utils.paths.*
shell.openExternal/openPath/showItemInFolder Utils.openExternal/openPath/showItemInFolder
shell.trashItem Utils.moveToTrash
safeStorage.encrypt/decrypt Bun.secrets.get/set/delete (OS keychain, key-value)
app.quit() Utils.quit()
electron-builder/forge electrobun build (built-in, BSDIFF patches)
file:// / custom protocol views://viewname/path
CSS -webkit-app-region: drag CSS class electrobun-webkit-app-region-drag

RPC pattern

Key difference: Electron IPC is unidirectional — ipcMain.handle only serves renderer→main, webContents.send only pushes main→renderer. Electrobun RPC is bidirectional in a single schema — both sides can define requests (async call/response) and messages (fire-and-forget) in one type. This eliminates the need for separate IPC channel registrations, event forwarders, and bridge layers. A single defineRPC<Schema>() call on each side replaces ipcMain.handle + webContents.send + contextBridge.exposeInMainWorld + ipcRenderer.invoke + ipcRenderer.on.

Shared schema type:

import type { RPCSchema } from "electrobun/bun";
type MySchema = {
  bun: RPCSchema<{
    requests: { myMethod: { params: { id: string }; response: Result } };
    messages: Record<string, never>;
  }>;
  webview: RPCSchema<{
    requests: Record<string, never>;
    messages: { myEvent: { data: string } };
  }>;
};

bun.requests = renderer→main (returns response). bun.messages = renderer→main (fire-and-forget). webview.requests/messages = main→renderer.

Bun side:

const rpc = BrowserView.defineRPC<MySchema>({ handlers: { requests: {...}, messages: {...} } });
const win = new BrowserWindow({ url: "views://main/index.html", rpc });
win.webview.rpc.myEvent({ data: "hello" });

Webview side:

const rpc = Electroview.defineRPC<MySchema>({ handlers: { requests: {...}, messages: {...} } });
const view = new Electroview({ rpc });
await view.rpc.request.myMethod({ id: "123" });

Phase 1: Compatibility Check

1.1 Scan Electron API usage

Main: import {...} from 'electron' (list all), BrowserWindow (options/methods/events/webContents), ipcMain.handle/.on (channels), webContents.send (channels), dialog.*, Menu.*, Tray, clipboard.*, globalShortcut.*, screen.*, session/cookies, shell.*, Notification, app.getPath(), app.on('ready')/whenReady()/'window-all-closed'/'before-quit', safeStorage, nativeTheme, protocol.registerFileProtocol, autoUpdater/electron-updater

Preload: contextBridge.exposeInMainWorld (remove), ipcRenderer.* (remove). Categorize each file: IPC bridge (remove) vs non-IPC (keep).

Renderer: window.electron/window.api/custom bridge names, remote module

Build: electron-builder/forge config, Vite/webpack electron plugins

Native modules: *.node, node-gyp, prebuild, ffi-napi, better-sqlite3, keytar, node-pty

1.2 Unsupported patterns

remote → RPC requests. webRequest → limited. No equivalent: desktopCapturer, powerMonitor, powerSaveBlocker, TouchBar, crashReporter, vibrancy/visualEffectState (NSVisualEffectView), trafficLightPosition. systemPreferences → limited. nodeIntegration: true → N/A. dialog.showSaveDialog → none yet. @electron/rebuild → Bun native modules.

1.3 Report template

## Compatibility Report
### Direct equivalents
- [ ] BrowserWindow (N instances)
- [ ] IPC: N handles, M sends → RPC
- [ ] Dialogs, Menu, Clipboard, Shortcuts, Screen, Session
### Requires refactoring
- [ ] Preload IPC bridge (N files) → strip
- [ ] Preload non-IPC (N files) → keep
### No equivalent
- [ ] ...
### Effort: N schemas, N preloads, N handlers, N events

STOP. Present report. Enter plan mode and draft a migration plan based on the report. Get user approval before proceeding.


Phase 2: Migration

Type-check after each step.

2.1 Install

bun add electrobun
bun remove electron electron-builder @electron-forge/* electron-devtools-installer @electron/rebuild

Remove electron vite/webpack plugins.

2.2 electrobun.config.ts

import type { ElectrobunConfig } from "electrobun";
export default {
  app: { name: "AppName", identifier: "com.x.app", version: "1.0.0" },
  build: {
    bun: { entrypoint: "src/main/index.ts" },
    views: { main: { entrypoint: "src/renderer/main.tsx" } },
    copy: { "src/renderer/index.html": "views/main/index.html" },
  },
} satisfies ElectrobunConfig;

With Vite: keep for dev, use copy for prod output.

2.3 RPC schemas

Per window type, create schema in src/common/rpc/.

ipcMain.handle(ch)bun.requests.ch. webContents.send(ch)webview.messages.ch. ipcRenderer.invoke(ch)rpc.request.ch(). ipcRenderer.on(ch) → message handler in defineRPC or rpc.addMessageListener.

Schema keys must be valid JS identifiers — rename :, ., / channels.

2.4 Preload

Strip: contextBridge.exposeInMainWorld(), all ipcRenderer, electron imports for these. Keep: polyfills, error handlers, globals, CSS injection, DOM prep. Delete file if only IPC bridge code. Wire kept preloads: preload: "views://main/preload.js". Remove global.d.ts/window.api type declarations.

2.5 Main process

BrowserWindow constructor:

Electron Electrobun
width,height,x,y frame: {width,height,x,y}
webPreferences.preload preload (top-level, accepts views://, remote URL, inline JS)
webPreferences.nodeIntegration N/A (never available)
webPreferences.contextIsolation N/A (always isolated)
webPreferences.partition partition (persist: prefix for persistence)
frame: false styleMask: {Borderless:true, Titled:false}
vibrancy / visualEffectState No equivalent (use CSS backdrop-filter + transparent: true)
trafficLightPosition No equivalent (system default only)
N/A sandbox: true (disables RPC), html: "...", rpc

styleMask (macOS): Borderless, Titled, Closable, Miniaturizable, Resizable, UnifiedTitleAndToolbar, FullScreen, FullSizeContentView, UtilityWindow, DocModalWindow, NonactivatingPanel, HUDWindow. titleBarStyle auto-sets styleMask.

BrowserWindow methods (differences only):

Electron Electrobun
restore() unminimize()
setBounds(rect) setFrame(x,y,w,h)
getBounds() getFrame(){x,y,width,height}
loadURL(url) webview.loadURL(url)
webContents webview (all webContents.* moves here)
webContents.openDevTools({mode}) webview.openDevTools() (no args)
N/A webview.toggleDevTools()

BrowserWindow events (differences only):

Electron Electrobun Data
'closed' 'close' {id}
'move'/'moved' 'move' {id,x,y}

Events also on Electrobun.events.on(name, ...).

App lifecycle:

// Electron:
app.whenReady().then(createWindow);
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
// Electrobun — no 'ready', starts immediately:
createWindow();
// exitOnLastWindowClosed in config. before-quit:
Electrobun.events.on('before-quit', (e) => { e.response = { allow: false }; });

openFileDialog options:

Electron Electrobun
properties: ['openFile'] canChooseFiles: true
properties: ['openDirectory'] canChooseDirectory: true
properties: ['multiSelections'] allowsMultipleSelection: true
defaultPath startingFolder
filters: [{extensions:[...]}] allowedFileTypes: "png,jpg" (comma-sep, "*" for all)

Returns string[] directly (not {filePaths, canceled}). showMessageBox options/return identical.

Menus:

// Electron: Menu.setApplicationMenu(Menu.buildFromTemplate(template));
// Electrobun — no buildFromTemplate:
ApplicationMenu.setApplicationMenu([
  { submenu: [{ label: 'Quit', role: 'quit' }] },
  { label: 'Edit', submenu: [{ role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }] },
]);

Menu item differences:

Electron Electrobun
click: () => {} action: "string" + Electrobun.events.on('application-menu-clicked', e => e.data.action)
accelerator: "CmdOrCtrl+S" accelerator: "s" (just key, modifier auto-applied)
visible: false hidden: true
toolTip tooltip

Roles: quit, hide, hideOthers, undo, redo, cut, copy, paste, pasteAndMatchStyle, delete, selectAll, minimize, close, toggleFullScreen, zoom, bringAllToFront, cycleThroughWindows. Separators: {type:"separator"} or {type:"divider"}. Linux: menus unsupported.

Context menus: ContextMenu.showContextMenu([...]) + Electrobun.events.on('context-menu-clicked', ...).

Shell: shell.*Utils.*. trashItemmoveToTrash (no restore metadata on macOS). openExternal/openPath return boolean.

Clipboard: clipboard.*()Utils.clipboard*(). readImage()Uint8Array (PNG) or null. availableFormats()["text","image","files","html"].

Paths: app.getPath(name)Utils.paths.{name}. All sync. userData is app-scoped: {appData}/{identifier}/{channel}. Extra: Utils.paths.config, .cache, .userCache, .userLogs.

Credentials:

// Electron: safeStorage.encryptString(value); safeStorage.decryptString(buffer);
// Electrobun (OS keychain, key-value):
await Bun.secrets.set({ service: "my-app", name: "api-key", value: "secret" });
await Bun.secrets.get({ service: "my-app", name: "api-key" }); // string | null
await Bun.secrets.delete({ service: "my-app", name: "api-key" }); // boolean

Not raw encrypt/decrypt — refactor to key-value by service+name.

GlobalShortcut, Screen, Session/Cookies: Import from "electrobun/bun". Same APIs.

2.6 Renderer

Replace bridge calls: window.api.call(ch, args)rpc.request.ch(args). window.api.on(ch, cb)rpc.addMessageListener('ch', cb).

Per-entrypoint rpc.ts:

import { Electroview } from "electrobun/view";
import type { MySchema } from "../common/rpc/my-schema.js";
export const rpc = Electroview.defineRPC<MySchema>({ handlers: { requests: {}, messages: {} } });
const view = new Electroview({ rpc });

CSS: -webkit-app-region: drag/no-drag → classes electrobun-webkit-app-region-drag/-no-drag.

Remove window.api/window.electron type declarations, global.d.ts/preload.d.ts.

2.7 Build

With Vite: keep for dev, copy maps output in electrobun.config.ts. Without: build.views in config.

2.8 Verify

bun run typecheckelectrobun build → test windows/RPC/events/menus/dialogs → electrobun build --env=stable

Pitfalls

  1. Preload ≠ delete — strip only contextBridge/ipcRenderer; keep polyfills/globals
  2. Channel names — RPC keys must be valid JS identifiers, rename :./ consistently
  3. safeStorageBun.secrets — key-value, not encrypt/decrypt
  4. remote → must become RPC requests
  5. Native modules — may need Bun-compatible alternatives
  6. Multi-window — each window type needs its own RPC schema
  7. Draggable regions — CSS property → CSS class
Weekly Installs
4
First Seen
11 days ago
Installed on
opencode4
github-copilot3
codex3
kimi-cli3
gemini-cli3
amp3