Files
paseo/docs/ELECTRON_MIGRATION.md
T

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 — Notification API works cross-platform
  • Delete runtime_manager.rs (1080 lines) — daemon becomes a child_process.fork() call
  • Delete all Rust code — lib.rs (683 lines), desktop_notifications.rs, build.rs, Cargo.toml dependencies
  • Delete sherpa rpath patching — electron-rebuild handles 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-forge or electron-builder
  • Configure BrowserWindow to load the Expo web build (same as Tauri's webview)
  • Set up electron-builder for 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 existing isTauri() / isTauriMac()
  • Introduce a isDesktop() helper that returns isTauri() || isElectron()
  • Create packages/app/src/desktop/electron/invoke-desktop-command.ts — Electron's equivalent of the Tauri invoke bridge, using ipcRenderer.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):

  1. Daemon lifecycle — child_process.fork() the daemon runner from the main process. Expose start/stop/restart/status via ipcMain.handle().
  2. Local daemon transport — WebSocket client in the main process using ws, proxied to renderer via IPC.
  3. Window management — Frameless window with overlay title bar, drag regions, zoom controls, fullscreen.
  4. Menu — Menu.buildFromTemplate() with zoom controls and macOS app menu.

Tier 2 — Desktop features:

  1. Notifications — new Notification() with click handlers.
  2. App updates — electron-updater with GitHub Releases.
  3. File dialogs — dialog.showOpenDialog() for directory picker.
  4. Confirm dialogs — dialog.showMessageBox().
  5. Open external URLs — shell.openExternal().
  6. Attachment file management — fs operations in main process via IPC.

Tier 3 — Polish:

  1. CLI symlink instructions — Generate platform-specific instructions in Node.
  2. Logging — electron-log or simple file-based logging.
  3. Single instance — app.requestSingleInstanceLock().
  4. Console forwarding — No bridge needed, main process is already Node.
  5. NSIS installer hooks — electron-builder NSIS 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 only isElectron() (rename to isDesktop())
  • Delete src-tauri/ and all Rust code
  • Delete build-managed-runtime.mjs and 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