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:
BrowserWindowoptions (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.*. trashItem → moveToTrash (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 typecheck → electrobun build → test windows/RPC/events/menus/dialogs → electrobun build --env=stable
Pitfalls
- Preload ≠ delete — strip only
contextBridge/ipcRenderer; keep polyfills/globals - Channel names — RPC keys must be valid JS identifiers, rename
:./consistently safeStorage→Bun.secrets— key-value, not encrypt/decryptremote→ must become RPC requests- Native modules — may need Bun-compatible alternatives
- Multi-window — each window type needs its own RPC schema
- Draggable regions — CSS property → CSS class