Skip to content

RFC: Move the desktop shell from Tauri to Electron #174

Closed

Description

@GoDiao

Status

Proposed. No migration work is scheduled. This RFC collects the evidence and the plan so the decision can be made once.

Summary

Move the desktop shell from Tauri 2 (system webviews: WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux) to Electron (bundled Chromium). The React frontend, the Bun sidecar and the CLI stay as they are; only the shell around them changes.

Why

1. WebKit layout is the remaining cause of streaming jank

After #169 and #171, React work per streaming flush is 1–3 ms. What's left is the engine's layout. Measured in #172 (same frontend, same Vite server, 60 KB reply, 750 flushes, 3 runs each):

Engine Layout per flush, median / p95
Chrome 156 ~1 ms / ~2.4 ms
WKWebView ~19–20 ms / ~265 ms

About 20× at the median and 100× at p95. WKWebView spends 250–350 ms on a flush that starts a heading or block KaTeX, and 25–45 ms on new table rows, code lines and paragraphs; Chrome stays around 1–3 ms for all of them. Opening a session with 200–1000 messages took 0.7–3.2 s to mount in WKWebView and 40–80 ms in Chrome. No frontend change gets WebKit within 20× of this.

Caveat: Chrome is not Electron's bundled Chromium, and this was a dev build. The engine is the same, so the gap should hold; the first migration step re-measures inside Electron.

2. One engine on every platform

Today each platform renders with a different engine, so a fix verified on macOS can behave differently on Windows, and Linux depends on WebKitGTK (#25 is a 12 GB+ memory freeze on Linux that has had no progress since June). With Electron, all three run the same Chromium.

3. A built-in browser the agent can drive

A built-in browser is planned. If the agent is to drive it, it needs a uniform automation protocol (Chrome DevTools Protocol). macOS's system webview doesn't offer one; Electron does, through WebContentsView and its debugger API.

4. Tooling

Electron has mature auto-update (electron-updater), signing and notarization tooling (electron-builder), and Chromium DevTools in every build.

Cost

  • Installer size. v0.6.1 is 42 MB (macOS .dmg) and 43 MB (Windows -setup.exe), of which the Bun sidecar is ~71 MB uncompressed. Electron adds roughly 80–100 MB uncompressed (about 40–60 MB compressed) per platform.
  • Memory. Chromium's renderer and GPU processes add roughly 100–200 MB at idle compared with a system webview. Each session already runs a CLI process of ~150–220 MB, so the relative increase shrinks as sessions are opened.
  • Migration work (from desktop/src-tauri at main@5e14099): about 2,600 lines of Rust and 20 commands, plus six plugins (shell, dialog, process, updater, notification, single-instance) and the tray. The frontend touches Tauri in 19 files: api/core (invoke) 23 imports, plugin-dialog 11, api/window 8, plugin-notification 5, api/event 5, plugin-process 4, api/webview 4, plugin-updater 3, plugin-shell 3, api/app 1.
  • Release pipeline. release.yml, smoke-test.yml, the macOS/Windows build scripts and scripts/release.ts are rewritten for electron-builder.

Plan

  1. Thin the shell first (Keep config and terminal logic in the sidecar instead of duplicating it in the Rust shell #173). Move config-directory resolution and terminal settings into the sidecar, so the shell keeps only OS-level work. This is worth doing whether or not we migrate, and it shrinks what has to be ported.
  2. Put one interface between the frontend and the shell. Replace direct @tauri-apps/* imports with a small desktop/src/lib/shell module (invoke, window, dialog, notification, updater, events). The Tauri build keeps working through it; the Electron build implements the same module over contextBridge/IPC.
  3. Electron spike. Main process that starts the sidecar, opens the window and implements the shell module; re-run the Measure streaming layout cost in long sessions #172 bench inside Electron and record installer size and idle memory against the Tauri build. Go/no-go here.
  4. Port the rest. PTY terminal (node-pty, or move it into the sidecar as Keep config and terminal logic in the sidecar instead of duplicating it in the Rust shell #173 suggests so H5 can use it too), tray, single instance, window state, notifications, app mode, zoom, session export.
  5. Release pipeline. electron-builder for .dmg and NSIS, signing and notarization, electron-updater replacing Ship in-app auto-update with electron-updater #118, smoke tests.
  6. Ship behind a release candidate, then remove desktop/src-tauri.

What this changes for other work

Open questions

  • Electron's Chromium vs the Chrome numbers: confirmed in step 3.
  • Do we keep the Bun sidecar as a separate process, or run parts of it in Electron's main process? Keeping it separate is the smaller change and keeps H5 working unchanged.
  • Minimum macOS and Windows versions after the move.
  • Scrolling up through a long history was not measured in Measure streaming layout cost in long sessions #172; measure it in the spike for both engines.

Activity

  1. added
    enhancementNew feature or request
    rfcRequest for comments / design discussion
    on Oct 24, 2026
  2. GoDiao commented on Oct 25, 2026

    @GoDiao
    OwnerAuthor

    Progress: steps 1 and 2 are done in #179 (26f91b7).

    Next is step 3: an Electron spike that starts the sidecar, opens the window and implements lib/shell. Then re-run the #172 layout bench inside it, and record installer size and idle memory against the Tauri build. That is the go/no-go point. Not scheduled yet.

  3. GoDiao commented on Oct 25, 2026

    @GoDiao
    OwnerAuthor

    Step 3 results: Electron spike

    Recommendation: go. Electron's bundled Chromium matches the Chrome numbers from #172, long sessions open and scroll about 10× faster than in WKWebView, and memory is not higher by the Activity Monitor metric. The cost is installer size: 152.7 MB vs 46.2 MB.

    Measured on an M1 Pro running macOS 26.6.2 (the same machine and OS as #172), on main@26f91b7 plus the spike. Electron 45.0.0 (Chrome 156.0.8078.48) vs WKWebView (system WebKit).

    What was built

    • desktop/electron/: a main process that starts the Bun sidecar with the same arguments and environment as the Tauri shell (reserved port, --app-root, DREAMCODER_PARENT_PID, H5 dist dir, XDG_CACHE_HOME) and kills it on quit. It opens the window either on the Vite dev server or on dist/, which in production is served from dreamcoder://localhost. A preload script exposes invoke/listen through contextBridge (contextIsolation and sandbox on).
    • desktop/src/lib/shell/electron.ts: an Electron implementation of every lib/shell export. A test type-checks it against typeof import('./index'). A Vite resolver plugin swaps it in only when DREAMCODER_SHELL=electron, so the Tauri build is unchanged and its bundle has no Electron code. The boundary test still holds.
    • Packaging: electron-builder produces an unsigned macOS arm64 .dmg. The sidecar and rg go to Contents/Resources/bin, and the packaged app starts and serves the UI.
    • End to end in the Electron production build, with a local mock Anthropic upstream (no real API): the main UI loads, a session opens from the sidebar, a message sent from the composer streams back into the DOM incrementally.

    lib/shell coverage:

    • Implemented: server URL, app mode (read only), zoom, relaunch, version, the four file/directory pickers, open external, window controls and resize events, request attention, menu-navigate listener.
    • Benign stubs: checkForAppUpdate returns null; onFileDrop is a no-op, so drops go through the composer's DOM handlers by content rather than by path.
    • Throw "not implemented in the Electron spike": terminal (all 8 functions), notifications (all 11), setAppMode, prepareAppModeRestart, writeSessionExport, prepareUpdateInstall, cancelUpdateInstall, startDragging.

    Streaming layout per flush

    Same bench, method and inputs as #172: 60,000-char reply, 80 characters every 50 ms, 750 flushes, 3 runs pooled, dev build with StrictMode, 1280 px content. The WKWebView and Chrome rows are the #172 results. They were not re-run because the machine and OS are the same, and since 5e14099 the only change under components/chat and chatStore is #179's import path.

    Engine N React render median / p95 Layout median / p95 / max
    Chrome 156 (#172) 0 / 200 / 1000 1.6–2.7 / 4.8–6.1 ms 0.9–1.0 / 2.3–2.5 / 6–30 ms
    WKWebView (#172) 0 / 200 / 1000 1.0 / 3.0–4.0 ms 19–20 / 264–268 / 351–561 ms
    Electron 45 0 2.0 / 5.4 ms 1.0 / 2.3 / 34 ms
    Electron 45 200 2.7 / 5.9 ms 1.1 / 2.6 / 6.5 ms
    Electron 45 1000 1.5 / 4.6 ms 0.9 / 2.1 / 5.5 ms

    Electron's per-run layout medians were 0.9–1.3 ms, and DOM size matched #172 (13.4k / 14.6k / 15.0–15.4k elements). The "Chrome is not Electron's Chromium" caveat in the RFC is answered.

    Mounting a session: 200 messages took 64–102 ms and 1000 messages 49–138 ms in Electron, vs 0.7–3.2 s in WKWebView (#172).

    Scrolling up through history (new)

    Method: seed 1000 finished messages, scroll the transcript container to the bottom, then decrease scrollTop by 120 px once per requestAnimationFrame until it reaches the top. Measured: wall time and the interval between frames. The bench ran in scroll-only mode, 3 page loads per engine, window in the foreground, on a 120 Hz display.

    Engine Frames Bottom → top Frame interval median / p95 / max Frames > 33 ms Frames > 100 ms
    WKWebView 2729 242–247 s 33 / 510–514 / 654–887 ms 984–1011 599–610
    Electron 45 2946 24.6–28.2 s 8.1–8.2 / 14.8–16.4 / 21–66 ms 0–59 0

    The frame counts differ because the virtualizer replaces estimated heights with measured ones while scrolling. WKWebView ran at DPR 1.6 and Electron at DPR 2, which if anything favours WKWebView.

    Size

    macOS arm64, unsigned. Both .dmg files are UDZO.

    .dmg .app Breakdown
    Tauri 46.2 MB 111 MiB shell binary 23 MB, sidecar 71 MB, rg 4 MB, resources 18 MiB
    Electron 152.7 MB 362 MiB Electron framework 267 MiB (its main binary 208 MiB), sidecar + rg 84 MiB, dist 19 MiB

    Electron adds 107 MB to the installer and 250 MiB to the installed app, about twice the RFC's estimate (40–60 MB compressed, 80–100 MB uncompressed). Keeping only the en and zh_CN Chromium locales is already included.

    Memory

    Method: packaged release builds of both shells. Each run started from a fresh config with only a mock provider. The metric is phys_footprint from /usr/bin/footprint (what Activity Monitor shows as Memory), summed over the app's processes. For Tauri this includes the WebKit GPU, Networking and WebContent processes. "Idle" was taken 30 s after startup with the main UI open and no session. "One session" was taken 30 s after a session was opened in the window and one mocked reply streamed into it. Values are the median of 3 runs.

    Shell processes Shell + sidecar
    Tauri, idle 157 MiB 224 MiB
    Electron, idle 137 MiB 204 MiB
    Tauri, one session 175 MiB 333 MiB
    Electron, one session 139 MiB 296 MiB

    Per process, Tauri: main 28 MiB, WebContent 98–112, GPU 31–33, Networking 8.5. Electron: main 43 MiB, renderer 43–50, GPU helper 44, network helper 7. Sidecar 67–69 MiB plus 89 MiB for the CLI with a session, the same in both. Summing RSS instead puts Electron at about 2× Tauri (277–360 vs 129–185 MiB), because RSS counts the shared Chromium framework pages once per process. The RFC's "+100–200 MB at idle" does not hold by footprint.

    What step 4 still needs

    • Terminal (PTY): node-pty in the main process (a native module rebuilt for every Electron upgrade), or move it into the sidecar as Keep config and terminal logic in the sidecar instead of duplicating it in the Rust shell #173 item 3 suggests.
    • Platform features: notifications, tray, single instance, window state, native menu, app mode switching, session export, updater (electron-updater).
    • Window drag: Electron has no startDragging. The title bar needs -webkit-app-region: drag instead of useWindowDrag.
    • File drop with paths: webUtils.getPathForFile in the preload.
    • Login-shell environment for the sidecar: the Tauri shell passes the login shell's environment (PATH etc.); the spike passes process.env, which is minimal when the app is launched from Finder.
    • CSP and origins: a CSP for the window; add dreamcoder://localhost to the server's local origins explicitly (it is accepted today only because its hostname is localhost).
    • Naming: rename isTauriRuntime to something shell-neutral.
    • Signing, notarization and other platforms: sign and notarize with the sidecar's JIT entitlements, then package for Windows (NSIS) and Linux, with smoke tests (step 5).

    Dependencies: the frontend and the sidecar keep theirs. @tauri-apps/* and src-tauri (with its Rust dependencies) go away. The new runtime dependency is electron-updater, plus node-pty if the terminal stays in the shell, so the terminal's location should be decided first. Building no longer needs Rust, but build-sidecars.ts uses rustc to detect the target triple and would need to change. Packaging needs Node.

    Not verified: signed builds, Windows and Linux, a real model, memory over a long real session, multi-client and reconnect paths under Electron.

    Checks

    • Root bun test: 1393 pass.
    • desktop: lint is clean and test has 1284 passed, 4 skipped.
    • tsc for desktop/electron is clean.

    The spike is on a local branch with three commits and has not been pushed yet.

    Scroll pass added to the #172 bench (temporary, not for merging)

    Added to desktop/src/bench/layoutBench.tsx from the #172 patch. runAll calls scrollPass(historyMessages(1000)) after the streaming passes and posts the result to /scroll. VITE_LAYOUT_BENCH_SCROLL_ONLY=1 skips the streaming passes. The page reloads itself until 3 runs are done (counted in sessionStorage).

    const SCROLL_STEP = 120
    const RUNS = 3
    
    function findScroller(): HTMLElement | null {
      let best: HTMLElement | null = null
      for (const el of Array.from(document.querySelectorAll<HTMLElement>('div'))) {
        const oy = getComputedStyle(el).overflowY
        if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight && (!best || el.scrollHeight > best.scrollHeight)) best = el
      }
      return best
    }
    
    async function scrollPass(messages: UIMessage[]) {
      setSession(messages, false)
      await frames(5)
      await sleep(500)
      const scroller = findScroller()
      if (!scroller) return { error: 'no scroller' }
      scroller.scrollTop = scroller.scrollHeight
      await frames(5)
      await sleep(500)
      const startHeight = scroller.scrollHeight
      const startTop = scroller.scrollTop
      const intervals: number[] = []
      const rafWork: number[] = []
      const t0 = performance.now()
      let last = t0
      let steps = 0
      while (scroller.scrollTop > 0 && steps < 5000) {
        scroller.scrollTop = Math.max(0, scroller.scrollTop - SCROLL_STEP)
        await new Promise<void>((r) => requestAnimationFrame(() => r()))
        const now = performance.now()
        intervals.push(now - last)
        last = now
        const l0 = performance.now()
        void document.body.offsetHeight
        rafWork.push(performance.now() - l0)
        steps++
      }
      const total = performance.now() - t0
      const sorted = [...intervals].sort((a, b) => a - b)
      const baseFrame = sorted[Math.floor(sorted.length * 0.1)] ?? 16.7
      return {
        steps,
        totalMs: total,
        startHeight,
        startTop,
        endHeight: scroller.scrollHeight,
        baseFrame,
        frame: summary(intervals),
        over33: intervals.filter((v) => v > 33.4).length,
        over50: intervals.filter((v) => v > 50).length,
        over100: intervals.filter((v) => v > 100).length,
        layoutAfterFrame: summary(rafWork),
        elements: document.querySelectorAll('*').length,
      }
    }
  4. GoDiao commented on Oct 25, 2026

    @GoDiao
    OwnerAuthor

    Decision: go. The spike results are in the comment above. The port (step 4) continues on the spike branch.

    Decisions for the port:

    • Terminal: move the PTY into the sidecar (Keep config and terminal logic in the sidecar instead of duplicating it in the Rust shell #173 item 3) instead of adding node-pty to the Electron main process. One place owns it, a later shell change doesn't touch it, and the H5 page can use it too.
    • Platforms: same as today, macOS Apple Silicon and Windows x64. No macOS x64 build.
    • Existing users: no bridge release is needed. The Tauri updater has no endpoints configured (Ship in-app auto-update with electron-updater #118 is not done), so users already install new versions by hand. The release notes will say that front-end preferences kept in the webview's storage (theme, zoom and similar) reset once; sessions, providers and other sidecar data are unaffected.
    • New dependencies: electron, electron-builder and electron-updater. No node-pty.

    #118 (Tauri auto-update) and #141 (Tauri 2.12) stay on hold and will be closed when the port lands.

  5. GoDiao commented on Oct 25, 2026

    @GoDiao
    OwnerAuthor

    Update to the dependency list above: no electron-updater for now. There is no update feed yet, and on macOS it needs a Developer ID signature we don't have, so the port keeps today's behaviour (no auto-update) and the updater comes with #118. New dependencies are electron and electron-builder only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:desktopTauri desktop appenhancementNew feature or requestrfcRequest for comments / design discussion

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions