11 KiB
Electron Migration
Why switch
Paseo Desktop is a webview that loads an Expo web build plus a Node daemon that manages AI coding agents. Tauri is designed for apps that need a thin native wrapper — it assumes your backend logic lives in Rust. Paseo's backend is entirely Node/TypeScript. The result is that we're reimplementing Electron's built-in Node bundling inside Tauri's resource system.
Pain points with Tauri today
Managed runtime bundling is the #1 source of complexity. build-managed-runtime.mjs (590 lines) downloads a Node distribution, packs three workspace tarballs, installs them with the bundled npm, separately installs sherpa-onnx platform packages, prunes ONNX/node-pty/ripgrep binaries per platform, and writes a runtime manifest. This exists solely because Tauri doesn't ship Node.
Linux is fragile. We wrote a custom AppImage Wayland patch (maybePatchLinuxAddonRunpath in sherpa-onnx-node-loader, plus AppImage patching scripts) because Tauri's Linux webview has Wayland issues. Electron handles Wayland/X11 transparently via Chromium's --ozone-platform-hint=auto.
Native notification callbacks required Objective-C. We wrote paseo_notifications.m (UNUserNotificationCenter delegate bridge) and compiled it into the Tauri build just to get notification click callbacks on macOS. Linux and Windows notification callbacks are unimplemented. Electron's Notification API provides click/reply/action callbacks cross-platform with zero native code.
Windows is undertested. The Rust code has #[cfg(windows)] blocks for named pipes, PowerShell Expand-Archive, CREATE_NO_WINDOW flags, NSIS installer hooks — all written speculatively with limited testing. Electron's cross-platform abstractions cover all of this.
IPC is boilerplate-heavy. Every desktop command requires a Rust #[tauri::command] function, a spawn_blocking wrapper, serde types, registration in generate_handler![], and a TypeScript invokeDesktopCommand() call. There are 22 commands today. In Electron, the main process is Node — these become direct function calls or simple ipcMain.handle() handlers in the same language as the rest of the codebase.
Tauri plugin gaps. Features like tray menus with dynamic updates, global shortcuts, deep links, system idle detection, auto-launch on login, and rich clipboard access all require Rust plugins (often per-platform). Electron provides these as JS API calls.
What we gain
- Delete
build-managed-runtime.mjs— Node comes with Electron - Delete the Wayland/AppImage patching — Chromium handles it
- Delete
paseo_notifications.m—NotificationAPI works cross-platform - Delete
runtime_manager.rs(1080 lines) — daemon becomes achild_process.fork()call - Delete all Rust code —
lib.rs(683 lines),desktop_notifications.rs,build.rs, Cargo.toml dependencies - Delete sherpa rpath patching —
electron-rebuildhandles native addon ABI matching - One language for the entire desktop app (TypeScript)
- Windows support works out of the box
- Mature auto-update via electron-updater (GitHub Releases, S3, etc.)
- Mature code signing for macOS and Windows without custom scripts
What we lose
- App size increases from ~20 MB to ~150-200 MB (ships Chromium)
- Memory footprint increases (Chromium process model)
- Tauri's Rust escape hatch — though we only use Rust because Tauri requires it, not because we need it
ONNX / Sherpa native addons
sherpa-onnx-node and onnxruntime-node ship prebuilt .node addons per platform. In Electron, @electron/rebuild rebuilds or verifies native addons against Electron's Node ABI. This is standard practice — packages like better-sqlite3, sharp, and node-pty all work this way. The manual sherpa platform package installation and rpath patching goes away entirely.
What can be collapsed
Build infrastructure (delete entirely)
| File | Lines | Purpose |
|---|---|---|
packages/desktop/scripts/build-managed-runtime.mjs |
590 | Bundle Node + workspaces into Tauri sidecar |
packages/desktop/scripts/validate-managed-runtime.mjs |
~100 | Validate bundled runtime integrity |
packages/desktop/scripts/sign-managed-runtime-macos.mjs |
~100 | Code-sign macOS sidecar bundles |
packages/desktop/scripts/managed-daemon-smoke.mjs |
~50 | Smoke test for managed daemon |
Electron replacement: None. Node is part of Electron. Workspace code loads directly via require/import.
Rust backend (delete entirely)
| File | Lines | Purpose |
|---|---|---|
src-tauri/src/lib.rs |
683 | App setup, IPC commands, menu, zoom |
src-tauri/src/runtime_manager.rs |
1080 | Managed runtime/daemon lifecycle |
src-tauri/src/desktop_notifications.rs |
~80 | Notification bridge |
src-tauri/src/main.rs |
~10 | Entry point |
src-tauri/build.rs |
~30 | Build script (compiles ObjC) |
src-tauri/Cargo.toml |
~30 | Rust dependencies |
src-tauri/tauri.conf.json |
~100 | Tauri configuration |
Electron replacement: A single main.ts (~200-300 lines) covering window creation, menu, IPC handlers, and daemon lifecycle.
Native platform code (delete entirely)
| File | Purpose |
|---|---|
src-tauri/macos/paseo_notifications.h |
ObjC notification bridge header |
src-tauri/macos/paseo_notifications.m |
ObjC UNUserNotificationCenter delegate |
src-tauri/Info.plist |
macOS bundle config |
src-tauri/Entitlements.plist |
macOS entitlements |
src-tauri/installer-hooks.nsh |
Windows NSIS post-install hooks |
Electron replacement: Notification API (2-3 lines per notification). macOS entitlements handled by electron-builder config.
Daemon management (massive simplification)
Tauri today: Bundled Node binary → spawn as child process → pipe stdout/stderr → poll daemon status --json in a loop → parse JSON → return via Tauri IPC.
Electron: child_process.fork('./packages/server/dist/scripts/daemon-runner.js') — same Node runtime, same ABI, direct IPC via Node's built-in channel.
Local daemon transport (simplification)
Tauri today: runtime_manager.rs implements a full WebSocket client in Rust (tokio-tungstenite) with session management, base64 binary encoding over Tauri events, and manual read/write task spawning. ~300 lines of Rust.
Electron: The renderer can connect to Unix sockets / named pipes directly via the main process using Node's ws package (already a dependency). Or use ipcMain to proxy — still TypeScript, still ~50 lines.
Attachment file management (simplification)
Tauri today: 6 Rust commands (write_attachment_base64, copy_attachment_file, read_file_base64, delete_attachment_file, garbage_collect_attachment_files) with path validation, base64 encoding/decoding, and directory traversal protection. ~200 lines of Rust.
Electron: Same logic in TypeScript using fs and app.getPath('userData'). ~50 lines.
Window management (simplification)
Tauri today: Custom tauri-window.ts with plugin:window|start_dragging, plugin:window|toggle_maximize, plugin:window|is_fullscreen invocations.
Electron: BrowserWindow API — win.maximize(), win.isFullScreen(), etc. Frameless window dragging via CSS -webkit-app-region: drag.
App updates (simplification)
Tauri today: Rust check_app_update / install_app_update commands using tauri_plugin_updater. Custom update check UI in the renderer.
Electron: electron-updater with autoUpdater.checkForUpdatesAndNotify(). Built-in download progress, restart handling, differential updates.
Migration plan
Guiding principle
Electron is mature. We are not doing anything new. Every feature we need has official documentation and established patterns. Never use workarounds — if something doesn't work, find the official way.
Phase 1: Scaffold in parallel
Create packages/desktop-electron alongside packages/desktop (Tauri). Both coexist.
- Initialize with
electron-forgeorelectron-builder - Configure
BrowserWindowto load the Expo web build (same as Tauri's webview) - Set up
electron-builderfor macOS, Linux, Windows packaging - Get a window rendering the Expo web app with no desktop features
Phase 2: Introduce isElectron branching
Add platform detection infrastructure in packages/app:
- Add
isElectron()/isElectronMac()alongside existingisTauri()/isTauriMac() - Introduce a
isDesktop()helper that returnsisTauri() || isElectron() - Create
packages/app/src/desktop/electron/invoke-desktop-command.ts— Electron's equivalent of the Tauri invoke bridge, usingipcRenderer.invoke() - Update
invokeDesktopCommand()to route to the correct backend based on platform
Phase 3: Port features incrementally
Work through each Tauri feature, adding the Electron variant. Never break Tauri while doing this.
Tier 1 — Core (must work for the app to be usable):
- Daemon lifecycle —
child_process.fork()the daemon runner from the main process. Exposestart/stop/restart/statusviaipcMain.handle(). - Local daemon transport — WebSocket client in the main process using
ws, proxied to renderer via IPC. - Window management — Frameless window with overlay title bar, drag regions, zoom controls, fullscreen.
- Menu —
Menu.buildFromTemplate()with zoom controls and macOS app menu.
Tier 2 — Desktop features:
- Notifications —
new Notification()with click handlers. - App updates —
electron-updaterwith GitHub Releases. - File dialogs —
dialog.showOpenDialog()for directory picker. - Confirm dialogs —
dialog.showMessageBox(). - Open external URLs —
shell.openExternal(). - Attachment file management —
fsoperations in main process via IPC.
Tier 3 — Polish:
- CLI symlink instructions — Generate platform-specific instructions in Node.
- Logging —
electron-logor simple file-based logging. - Single instance —
app.requestSingleInstanceLock(). - Console forwarding — No bridge needed, main process is already Node.
- NSIS installer hooks —
electron-builderNSIS config for CLI symlink.
Phase 4: Feature parity validation
- Test all features on macOS, Linux (X11 + Wayland), Windows
- Verify sherpa-onnx-node loads correctly via
@electron/rebuild - Verify daemon start/stop/restart lifecycle
- Verify auto-update flow
- Verify notification callbacks on all platforms
- Smoke test CLI passthrough mode
Phase 5: Cut over
- Update CI to build Electron packages instead of Tauri
- Remove
packages/desktop(Tauri) - Remove all
isTauri()branching, keep onlyisElectron()(rename toisDesktop()) - Delete
src-tauri/and all Rust code - Delete
build-managed-runtime.mjsand related scripts - Delete sherpa rpath patching code
Estimated code impact
| Category | Lines removed | Lines added |
|---|---|---|
| Rust backend | ~1,900 | 0 |
| ObjC native code | ~150 | 0 |
| Build scripts | ~850 | 0 |
| Tauri TypeScript bridge | ~300 | ~150 (Electron IPC) |
| Electron main process | 0 | ~300 |
| Net | ~3,200 removed | ~450 added |