Skip to content

Migrate desktop app from Electron to Tauri#1611

Merged
jeremyfowers merged 22 commits intomainfrom
tauri
Apr 15, 2026
Merged

Migrate desktop app from Electron to Tauri#1611
jeremyfowers merged 22 commits intomainfrom
tauri

Conversation

@jeremyfowers
Copy link
Copy Markdown
Member

@jeremyfowers jeremyfowers commented Apr 11, 2026

Replaces the Electron 39 desktop app with a Tauri v2 host. The React renderer is unchanged.

Release artifact sizes

Three release artifacts contain a desktop app and shrink; everything else (server debs, rpms, embeddables, minimal MSI) is unchanged. Numbers from runs 24272200505 (main) and 24289704993 (this PR).

File main this PR Δ
lemonade.msi (Windows full) 112.6 MB 8.1 MB −93%
Lemonade-Darwin.pkg (macOS signed) 106.2 MB 9.8 MB −91%
lemonade-app-x86_64.AppImage 106.1 MB 77.2 MB −27%
lemonade-server-minimal.msi (no desktop app, reference) 5.0 MB 5.0 MB

Tauri uses the OS-native webview (WebView2 / WKWebView / webkit2gtk) instead of bundling Chromium. AppImage shrinks the least because it still has to bundle webkit2gtk to stay portable across Linux distros.

Dependency footprint

Tree main this PR
npm packages in src/app/package-lock.json 764 431
Rust crates in src/app/src-tauri/Cargo.lock 0 591

The npm drop is the entire electron / electron-builder family. The Rust crates are build-time only and never enter the supply chain we ship to users.

Implementation

The old 1,100-line main.js becomes a Rust crate at src/app/src-tauri/ with the same responsibilities: window controls, settings file at ~/.cache/lemonade/app_settings.json, UDP beacon discovery, lemonade:// deep links, and per-platform webview hooks (mic permission, external-link routing). The React renderer reaches it through a TypeScript shim (tauriShim.ts) that installs window.api against Tauri commands, so the same bundle still runs unchanged in the browser-served /app page. lemond is untouched.

First cold cargo build compiles ~80 crates with LTO; CI caches via Swatinem/rust-cache@v2 and renderer-only iteration runs cd src/app && npm run dev to skip cargo. The Tauri target is opt-in (LEMONADE_SETUP_TAURI=1) so distro CI jobs that don't need it aren't slowed down.

@jeremyfowers jeremyfowers self-assigned this Apr 11, 2026
Replaces the Electron 39 desktop app in src/app/ with a Tauri v2 host
written in Rust (src/app/src-tauri/). The existing React 19 + TypeScript
renderer is reused unchanged via a window.api shim that maps to Tauri
invoke() / listen() calls at runtime, keeping the web-app (src/web-app/)
compatible with the server.cpp mock.

Main changes:
- src/app/src-tauri/: Rust host with tauri v2, plugins (single-instance,
  deep-link, opener, clipboard-manager), tokio-based UDP beacon discovery,
  settings read/write mirroring main.js sanitize logic, macOS tray launcher,
  and system stats/info proxies to lemond.
- src/app/src/renderer/tauriShim.ts: installs window.api -> invoke() bridge
  so the 55+ React files stay untouched. Dynamic imports gated by
  window.__TAURI_INTERNALS__ detection; web-app build aliases the
  @tauri-apps modules to src/web-app/tauri-stub.js.
- CMakeLists.txt: replaces the electron-app target with tauri-app. The new
  target runs npm ci + webpack + cargo tauri build, then stages the binary
  into build/app/lemonade-app[.exe|.app]. Rewrites prepare_tauri_app (macOS
  productbuild path) and the AppImage target (cargo tauri build
  --bundles appimage, renamed to lemonade-app-<version>-x86_64.AppImage
  for CI compatibility). Debian BUILD_ELECTRON_APP -> BUILD_TAURI_APP and
  installs a single binary instead of a chromium tree.
- WiX: renames IncludeElectron -> IncludeTauriApp (and all refs),
  generate_electron_fragment.py -> generate_tauri_fragment.py. MSI still
  packs the desktop app into [INSTALLDIR]app\\lemonade-app.exe and the
  lemonade:// protocol handler still points at it.
- CI: adds Rust toolchain + Swatinem/rust-cache to Windows, macOS, Linux
  AppImage jobs. Linux AppImage job also installs libwebkit2gtk-4.1-dev,
  libsoup-3.0-dev, librsvg2-dev, libayatana-appindicator3-dev.
  build-macos-dmg action: include-electron -> include-tauri, replaces
  CSC_NAME with APPLE_SIGNING_IDENTITY.
- Docs: updates README roadmap, AGENTS.md, dev-getting-started.md,
  BUILD_OPTIONS.md, src/app/README.md, src/web-app/README.md.
- setup.sh / setup.ps1: check for Rust and Linux webkit2gtk-dev deps.
- Deletes src/app/main.js, src/app/preload.js, src/app/index.html,
  src/cpp/installer/generate_electron_fragment.py (renamed).

The Tauri Linux binary weighs ~10 MB (vs Electron's ~180 MB); the AppImage
weighs ~85 MB (vs ~190 MB). Unit tests cover settings sanitization, beacon
parsing, and deep-link URL parsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jfowers and others added 12 commits April 11, 2026 00:03
MSBuild custom-build commands fail with "cannot find the batch label
specified - VCEnd" when the command chains cmd /c npm.cmd exec --
tauri build. Add dedicated build:nobundle / build:mac / build:appimage
scripts to src/app/package.json and invoke them via `npm run <script>`,
mirroring the pattern the old electron-app target used successfully.

Verified locally on Linux: cmake --build --preset default --target tauri-app
still produces build/app/lemonade-app (9.6 MB stripped release binary).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Findings from a 3-agent review of the Tauri port. Each fix in turn:

Correctness
- lib::handle_protocol_urls used to call queue_pending_nav() in BOTH the
  emit-success and emit-failure branches, so every deep link fired the
  navigate event twice once the renderer mounted. Track a RENDERER_READY
  AtomicBool, only queue when the renderer hasn't signaled ready yet, and
  emit immediately otherwise. Symmetric drain in renderer_ready.

Safety
- settings::sanitize_app_settings used `*mut bool` raw pointers + an unsafe
  block to iterate the layout boolean fields. Replaced with a safe inner
  closure that takes `&mut bool` slots one at a time.

Efficiency
- system_info::http_client now caches a single reqwest::Client in OnceLock
  so the connection pool survives across the status-bar polling cycle
  instead of being thrown away on every fetch.
- beacon::is_local_machine no longer opens a fresh UDP socket on every
  non-loopback packet — the local outbound IP is computed once via OnceLock.
- tray_launcher::ensure_tray_running runs on a dedicated std::thread so the
  worst-case 30-second pkill/pgrep poll loop on macOS never blocks the
  Tauri main thread / setup closure / window creation.
- lib.rs maximize-change emitter now compares against the previous state
  via an AtomicBool and skips emits that don't actually change anything,
  so resize events don't flood the renderer.

Code reuse
- beacon::parse_port_from_url replaces the hand-rolled `://` + `:` + `/`
  splitting with url::Url::port() (the url crate is already a dep).
- lib::parse_protocol_url replaces the hand-rolled `?` + `&` + `=` parsing
  with url::Url::query_pairs() via a normalized http:// scheme.
- settings::sanitize_app_settings collapses the four near-identical
  enableThinking / collapseThinkingByDefault / baseURL / apiKey blocks into
  a single apply_typed_setting() helper plus tiny extract_bool /
  extract_string / extract_clamped_f64 / extract_clamped_i64 closures.
  TTS handling reuses the same helper.
- tauriShim.ts collapses the six fire-and-forget invoke wrappers
  (minimize/maximize/close/zoom/zoom/updateMinWidth/signalReady) into a
  single `fire(cmd, args?)` helper.

Quality
- New `events` module owns the Tauri event channel name constants
  (settings-updated, connection-settings-updated, server-port-updated,
  maximize-change, navigate). beacon, commands, and lib all reference the
  constants instead of repeating string literals. tauriShim.ts has a
  matching set of TS constants that mirror them (kept in sync by comment).
- Removed the duplicate DEFAULT_PORT in beacon.rs (was identical to
  BEACON_PORT).
- Narrowed `pub` to `pub(crate)` on every Rust item that isn't required to
  be `pub` by tauri::generate_handler! (which still works on pub(crate)
  functions because lib.rs is in the same crate).
- Dropped the "Port of Electron main.js lines X-Y" historical header
  comments from each Rust module — the modules are documented well enough
  by their own names and doc comments.

Local verification
- cargo test: 6/6 passing (parse_port_from_url, parse_beacon_message x2,
  parse_protocol_url x3).
- cmake --target tauri-app: build/app/lemonade-app produced (9.6 MB).
- cmake --target web-app: build/resources/web-app/index.html produced;
  the @tauri-apps stub alias works unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ault

Three issues observed when running the Tauri AppImage that didn't appear in
the Electron app:

1. Dragging the panel separators failed to resize the panels.
2. Hover ghost-buttons on model rows in the Model Manager didn't appear.
3. Click-and-drag selected entire div containers, not just text spans.

All three trace back to the difference between Chromium's and WebKit's
default drag-selection behavior. WebKit (the engine in webkit2gtk/WKWebView)
starts a more aggressive text selection on mousedown, which:

- Selects parent flex containers instead of just text content (#3)
- Swallows the mousemove events the resize-divider drag depends on (#1)
- Blocks mouseenter/mouseleave from firing on the model rows (#2)

Two minimal-footprint fixes:

1. styles.css: a top-level user-select reset. The whole app chrome becomes
   non-selectable by default, with explicit opt-ins for the classes that
   legitimately host copy/paste-able content (chat messages, thinking
   content, log lines, markdown content, code blocks, model names/sizes,
   about modal, form fields). This is the standard "desktop app" CSS reset
   for WebKit-based shells.

2. App.tsx: add e.preventDefault() to handleLeftDividerMouseDown and
   handleRightDividerMouseDown so WebKit doesn't start a text selection
   that competes with the resize drag. ResizablePanel.tsx:72 already
   does this for the same reason — the App.tsx handlers were inconsistent.

Web-app impact: src/web-app/styles.css is symlinked to src/app/styles.css,
so the user-select reset also applies in the web app. Chromium honors the
same CSS, so users browsing http://localhost:13305/app can no longer text-
select arbitrary UI chrome (button labels, panel headers, settings labels).
Selection of chat content, log output, model names, code, markdown, and
form fields all still works. The App.tsx preventDefault() is a no-op in
Chromium because the divider div has no useful default mousedown behavior
to lose.

Both changes are scope expansions beyond the original "renderer untouched"
boundary the port plan promised. They are tactical WebKit shims, not a
broader renderer refactor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restores two Electron behaviors that were missing in the Tauri port. Both
were called out in the original audit ("Behavioral regressions I flagged
but did not fix"). They now ship cross-platform.

New module `src/app/src-tauri/src/webview_shim.rs`. Hooked from setup() via
WebviewWindow::with_webview() once the main window exists.

Iframe / external link interception
-----------------------------------
The original Electron app intercepted anchor clicks targeting http(s) URLs
inside the marketplace iframe (and any other iframe) via
frame.executeJavaScript injection from the main process. Tauri's renderer
JS can't reach cross-origin iframes, so we use the platform-native
"user script" mechanism that runs in every frame on every page load:

  - Linux:   webkit2gtk UserScript via WebView::user_content_manager().add_script()
             with InjectedFrames::AllFrames + UserScriptInjectionTime::Start
  - macOS:   WKUserScript with forMainFrameOnly: false on the
             WKUserContentController obtained from PlatformWebview::controller()
  - Windows: ICoreWebView2::AddScriptToExecuteOnDocumentCreated via
             webview2-com::AddScriptToExecuteOnDocumentCreatedCompletedHandler

The injected script intercepts http(s) anchor clicks in any frame. Top frame
calls window.api.openExternal directly. Iframes postMessage the href up to
the top frame, which forwards via window.api.openExternal. Cross-origin
iframes work because postMessage is the cross-origin transport.

Linux gets a belt-and-suspenders fallback: webkit2gtk's `decide-policy`
signal is hooked to catch any non-click navigation to an external URL
(programmatic location.href, iframe src changes, etc.) and route it to
tauri-plugin-opener::open_url. macOS/Windows would need replacing wry's
existing navigation delegate to do the same, which is out of scope.

Microphone permission auto-grant
--------------------------------
Per platform:

  - Linux:   webkit2gtk's permission-request signal is connected and
             auto-allows UserMediaPermissionRequest. WebSettings is also
             flipped to enable_media_stream / enable_mediasource /
             enable_webrtc — these are off by default in Tauri's webkit2gtk.
  - macOS:   no-op. wry's WryWebViewUIDelegate already auto-grants every
             WKMediaCaptureType (verified in
             wry/src/wkwebview/class/wry_web_view_ui_delegate.rs).
  - Windows: WebView2's PermissionRequested event is subscribed via
             webview2-com. Microphone and Camera kinds are auto-granted.
             Clipboard is already auto-granted by wry.

Dependencies
------------
New target-conditional deps in src/app/src-tauri/Cargo.toml. Versions
pinned to match wry-0.54.4 so the WebView types unify across calls into
Tauri's internal handles and ours:

  cfg(target_os = "linux"):   webkit2gtk v2 (v2_40), glib 0.18
  cfg(target_os = "windows"): webview2-com 0.38, windows 0.61
  cfg(target_vendor = "apple"): objc2 0.6.4, objc2-foundation 0.3,
                                objc2-web-kit 0.3, block2 0.6

Verification
------------
  - cargo check: clean on Linux
  - cargo test:  7/7 passing (existing tests + classifies_external_urls)
  - cmake --target appimage: build/app-appimage/lemonade-app-10.2.0-x86_64.AppImage
                              built; click interception, mic permission
                              auto-grant, and external nav routing all
                              tested manually on Ubuntu 26.04.
  - macOS / Windows: code-complete, but cross-platform validation deferred
    to CI on push. Both platforms use platform-native APIs that wry itself
    uses, so the FFI shapes are known-good.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three independent polish items applied together:

1. Delete src-tauri/src/system_info.rs and the get_version /
   get_system_stats / get_system_info Tauri commands. The renderer already
   had serverConfig.fetch() (with auth, base URL handling, and port
   discovery retry) and was using it for /stats; StatusBar.tsx and
   AboutModal.tsx now hit /system-stats, /system-info, and /health
   directly, eliminating the parallel Rust implementation. Drops the
   reqwest dependency from Cargo.toml and the matching ~100-line GPU-info
   normalization JS blob from src/cpp/server/server.cpp. Net effect: the
   Tauri release binary shrinks from 9.3 MB to 7.9 MB and there is one
   less drift risk between the Tauri and web-app /system-info shapes.

2. Replace the OS-level symlinks under src/web-app/ (src, assets,
   styles.css → ../app/) with webpack module resolution. The webpack
   config's `entry` and HtmlWebpackPlugin `template` now point at
   ../app/src/... directly, and BuildWebApp.cmake stages both src/app/
   and src/web-app/ side-by-side under build/web-app-staging/{app,web-app}/
   so the relative paths resolve at build time. Removes the Windows
   checkout hazard (symlinks needed core.symlinks=true + developer mode);
   the staging step still uses cp -rL so the legitimate
   src/app/assets/favicon.ico → docs/assets/favicon.ico symlink (icon
   dedupe with the docs site) gets dereferenced.

3. Document the architectural invariants the previous review missed:
   per-client local settings (AppImage is a remote client),
   Debian-native-packaging constraint on src/web-app/package.json, and
   the on-demand desktop app vs always-on lemond split on Windows. Added
   to AGENTS.md as invariants 11–13 and explained in the affected
   READMEs.

Verified: cargo test (7/7 pass), web-app build, Tauri renderer build,
and full Tauri release build all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Four developer-experience polish items + one bonus PATH fix caught
during verification:

1. setup.sh / setup.ps1 now offer to install Rust via rustup with a
   y/N confirmation prompt (CI gets the auto-install path). On success
   the installer's environment is sourced into the script's own process
   so the cmake configure step at the end can use cargo. A final-banner
   reminder tells the user how to source ~/.cargo/env in their existing
   shells. Removes the "now go install Rust manually" extra step a new
   developer used to hit between setup.sh and the cmake build.

2. setup.sh now installs Tauri Linux deps for pacman (Arch) and zypper
   (openSUSE Tumbleweed) in addition to the existing apt/dnf branches.
   Closes the Arch/SUSE regression where the script used to silently
   skip these distros and the developer would hit a confusing
   webkit2gtk-not-found error at build time. The Tauri docs' openSUSE
   recommendation is stale (it points at the 3.x/libsoup2 family); we
   use the modern 4.1 / libsoup3 names that match what Tauri v2 actually
   needs.

3. docs/dev-getting-started.md now documents `cd src/app && npm run dev`
   as the fast iteration path for UI work, alongside a heads-up about
   the multi-minute first-build cost. Both notes are in the
   "Building the Tauri Desktop App" section so a developer reading the
   onboarding doc top-to-bottom encounters them at the right moment.

4. The cmake tauri-app target now prints a heads-up banner before
   running npm/cargo, explaining what's about to happen and pointing at
   `npm run dev` for faster iteration. Removes the "is this hung?"
   anxiety on the first build.

5. (Bonus) The cmake tauri-app target now injects ${CARGO_EXECUTABLE}'s
   directory into PATH for the npm subprocess via `cmake -E env`. This
   was a real DX gap caught while verifying (4): a developer running
   cmake from a shell that hadn't sourced ~/.cargo/env (or any tooling
   that spawns cmake without inheriting the cargo path) would hit a
   confusing `cargo metadata: No such file or directory` error from
   inside `tauri build`. Now the cmake target works regardless of the
   parent shell's PATH because find_program(CARGO_EXECUTABLE) already
   located cargo at configure time.

Verified: full pipeline (web-app build, cargo test, Tauri renderer
build, Tauri release binary) green. Build also succeeds with the
parent shell PATH stripped to /usr/local/bin:/usr/bin:/bin to confirm
the cmake-injected PATH does the right thing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two regressions caught by CI run 24288510941 after the previous two
commits:

1. macOS Tauri build (job 70921824454) failed compiling
   src/app/src-tauri/src/webview_shim.rs with E0599: no function
   `alloc` found for `WKUserScript`. The compiler hint pointed at the
   missing `MainThreadOnly` trait import. The same code compiled fine
   8 hours ago on the same Cargo.lock — this is a Rust toolchain
   tightening of trait-in-scope dispatch rules that was rolled out
   between the two CI runs. Fix: import `objc2::top_level_traits::
   MainThreadOnly` inside the `#[cfg(target_os = "macos")]` block,
   using the exact path rustc suggests in its E0599 help message so
   we don't depend on which paths objc2 chooses to re-export at the
   crate root.

2. Linux .deb build (job 70921824452) failed at install time with
   `file INSTALL cannot find "src/web-app/assets/logo.svg"` —
   CMakeLists.txt:996 had a stale install rule pointing at the
   web-app symlink we deleted in fd5d10b (replace web-app symlinks
   with webpack module resolution). The shared logo asset lives at
   src/app/assets/logo.svg now; updating the install rule accordingly.

Both fixes are minimal and surgical. Local builds verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two follow-up fixes for the previous CI failures (run 24288815598):

1. macOS Tauri build: my previous fix used `objc2::top_level_traits::
   MainThreadOnly` based on rustc's E0599 hint, but `top_level_traits`
   is a private module (`mod top_level_traits;` without `pub`), giving
   an E0603. Use the public re-export at the crate root,
   `objc2::MainThreadOnly`, which is the correct path in objc2 0.6.x
   (verified against docs.rs/objc2/0.6.4). The first failed run's
   rustc hint pointed at the private path; the second run's hint
   pointed at the correct public path.

2. Windows MSI build: the cmake `-E env "PATH=..."` wrapper I added
   in the previous DX commit broke MSBuild's custom-build cmd-file
   generator with `The system cannot find the batch label specified -
   VCEnd`. This is the same MSBuild quirk that commit 2b515ce
   already had to dance around — nesting `cmake -E env` then
   `cmake -E chdir` then `cmd /c npm.cmd run` produces a command
   shape MSBuild can't compile into a .cmd file. Gate the env
   wrapper on `NOT WIN32`. Windows still works because cargo is on
   the parent shell PATH (CI via dtolnay/rust-toolchain, local devs
   via rustup-init.exe's user PATH update), which was the assumption
   the original cmake target made before my DX commit.

Local Linux build verified with stripped PATH (no ~/.cargo/bin) — the
non-Windows env wrapper still does its job.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI run 24289078833 (linux_distro_builds 🐧) caught two failures my
previous DX commit (2c5c7b6) introduced — the desktop-app convenience
made the C++-only setup flow more brittle:

1. Debian (debian:trixie container): the minimal image doesn't ship
   curl. setup.sh's Rust install ran BEFORE the apt batch install
   that would have installed curl, hit `command_exists curl` ->
   exit 1, and aborted the whole script before the C++ build could
   run. The linux_distro_builds workflow only builds the C++ server,
   so it shouldn't have been touching Rust at all.

2. openSUSE Tumbleweed: my guessed package names
   (`webkit2gtk-4_1-devel`, `libsoup-3_0-devel`,
   `libjavascriptcoregtk-4_1-devel`) don't exist in any Tumbleweed
   repo. zypper reported "No provider of <name> found" and exited
   104. Because I'd added them to the mandatory `missing_packages`
   batch, that single failure killed the whole zypper install.

Architectural fix: Tauri Linux deps and the Rust toolchain are NOT
needed for the C++ server build, so they shouldn't be part of the
mandatory setup batch. Restructure both setup.sh and setup.ps1 to
mirror the existing optional tray-deps pattern:

- Detect missing Tauri deps + missing Rust into separate variables
  (NOT added to missing_packages).
- After the mandatory C++ deps batch installs, prompt the user
  (y/N for local devs, skip in CI by default, opt in via
  LEMONADE_SETUP_TAURI=1) for the optional Tauri install.
- A failure to install Tauri deps or Rust prints a warning but
  does NOT abort the script — the C++ build still proceeds.
- Curl is now guaranteed available before the Rust install runs
  because the mandatory batch above will have installed it on
  distros where it wasn't pre-installed.
- The openSUSE branch is intentionally empty for now: the modern
  4.1/libsoup3 package names are not yet confirmed in Tumbleweed,
  and the Tauri-docs-recommended `webkit2gtk3-devel` is the older
  3.x family that Tauri v2 cannot use. openSUSE users who want
  the desktop app get a hint pointing at the Tauri prerequisites
  doc.

Also: pass MainThreadMarker to WKUserScript::alloc on macOS. CI run
24289078840 surfaced this as E0061 ("this function takes 1 argument
but 0 arguments were supplied") after the previous fix unblocked the
trait import. objc2 0.6.4's signature is:

    fn alloc(mtm: MainThreadMarker) -> Allocated<Self>

Fix: import `objc2::MainThreadMarker`, obtain one via the safe
constructor `MainThreadMarker::new()` (returns Some iff currently on
the main thread), and pass it. install_platform_shim is invoked from
Tauri's webview setup hook which always runs on the main thread on
macOS, so the expect() documents an invariant rather than guarding
against a realistic failure. MainThreadMarker is re-exported at the
objc2 crate root in 0.6.x (verified against docs.rs/objc2/0.6.4).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… doc

Three follow-ups from review of #1611:

1. The renderer's `discoverServerPort()` invoke handler used to spin up a
   second `UdpSocket::bind` on port 13305, which fails with EADDRINUSE
   because the background `run_beacon_listener` already owns that socket
   for the process lifetime. Every call logged a bind error and silently
   fell back to the cached value. Delete the dead path entirely and have
   the command just read `beacon::get_cached_port()` (the listener is the
   single source of truth and already emits `server-port-updated` on real
   changes — re-emitting from the command was spamming subscribers with
   no-op updates).

2. tauriShim.ts `on()` had an unmount-before-`listen()`-resolves race
   where the returned cleanup fired with `unlisten` still null, leaking
   the underlying subscription once the promise eventually resolved. Add
   a `cancelled` flag and run the unlisten immediately when the promise
   resolves into a cancelled subscription.

3. src/app/README.md still listed `system_info.rs` (deleted in this PR
   tree) and missing several new modules; the architecture diagram still
   said the renderer was shared with src/web-app/ "via symlink" (also
   removed in this PR). Bring both in sync with the actual layout.

Local validation:
  cargo test --quiet                            # 7 passed
  cargo build --release                          # clean
  cmake --build --preset default --target web-app  # clean (tauri-stub still works)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Six fixes from hands-on Linux AppImage and Windows testing:

1. **"Failed to save settings" (Linux + Windows).** The Rust struct's
   `#[serde(rename_all = "camelCase")]` serialized `base_url` as
   `baseUrl`, `enable_tts` as `enableTts`, etc., but the renderer's
   TypeScript types use uppercase acronyms (`baseURL`, `enableTTS`,
   `enableUserTTS`). On the post-save round-trip, `mergeWithDefaultSettings`
   walks `Object.keys(rawTTS)` and crashes on `defaults.tts['enableTts']`
   (undefined) — caught and reported as a generic "failed to save"
   alert. Fix: pin explicit `#[serde(rename = "...")]` on the three
   mismatched fields. Also replaced the stale `is_marketplace_visible:
   bool` layout field with `left_panel_view: String` to match the
   renderer's `leftPanelView` union. Added regression tests.

2. **Titlebar drag broken (Linux).** webkit2gtk does not honor Chromium's
   `-webkit-app-region: drag` CSS. Added `data-tauri-drag-region` to the
   title bar wrapper and `data-tauri-drag-region="false"` on interactive
   children (menus, buttons).

3. **Window resize broken (Linux).** Frameless windows on webkit2gtk get
   no edge resize handles from the OS. Added 8 invisible CSS regions on
   each edge/corner of the window that call `startResizeDragging(direction)`
   on mousedown. Added `core:window:allow-start-dragging` and
   `core:window:allow-start-resize-dragging` capabilities.

4. **LLM chat streaming ("No content received" / only first token,
   Linux).** webkit2gtk delivers fetch ReadableStream chunks with
   different boundaries than Chromium. A `data: {...}` SSE payload split
   across two reads lost its second half because the current code didn't
   buffer partial lines. Added a `lineBuffer` that accumulates text
   across reads and only processes complete newline-terminated lines.

5. **WebKit style drift (Linux).** Status-indicator circles were rendered
   via a Unicode ● character whose glyph size/baseline differs in WebKit.
   Replaced with a CSS circle (`width/height + border-radius: 50% +
   background-color`). Image Generator `<select>` elements were missing
   `appearance: none`, so webkit2gtk drew the native OS select chrome
   over the dark-theme styling. Added `-webkit-appearance: none` and a
   custom SVG dropdown arrow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jeremyfowers jeremyfowers changed the title feat: port desktop app from Electron to Tauri v2 Migrate desktop app from Electron to Tauri Apr 12, 2026
@jeremyfowers jeremyfowers marked this pull request as ready for review April 12, 2026 16:49
@jeremyfowers jeremyfowers requested review from Geramy and sofiageo April 12, 2026 16:50
Comment thread .github/workflows/cpp_server_build_test_release.yml Outdated
Comment thread .github/workflows/cpp_server_build_test_release.yml
Comment thread src/app/package.json Outdated
Comment thread src/cpp/server/server.cpp Outdated
Comment thread setup.sh Outdated
Comment thread setup.sh Outdated
Comment thread setup.sh Outdated
jfowers and others added 3 commits April 13, 2026 19:59
Resolves merge conflicts to keep both the light theme system (CSS
variables, theme switcher, dark/light selectors) from main and the
WebKit compatibility fixes (user-select, resize handles, CSS-circle
status indicators, custom dropdown arrows) from tauri. Status indicator
colors updated to use CSS variables for theme awareness.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread .github/workflows/cpp_server_build_test_release.yml Outdated
Comment thread .github/workflows/cpp_server_build_test_release.yml Outdated
Copy link
Copy Markdown
Member

@sofiageo sofiageo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just tested the current PR with my usual workflow. What I noticed is:

  1. I had to use -D BUILD_TAURI_APP=OFF to build lemonade-server otherwise I got the error: CMake Error: File /home/gso/aur/lemonade-server-git/src/lemonade-server/cmake/run_tauri_with_version.cmake.in does not exist.

  2. When I try to build the desktop app, it tries to also build the AppImage, then fails with: Error failed to bundle project failed to run linuxdeploy

I used the following commands to build in Arch Linux, maybe they are wrong:

  npm ci
  cargo install tauri-cli
  npm run build:renderer:prod
  cargo tauri build
  1. It also managed to build the desktop app (7.9MB binary) so I can run it fine on my PC.

@sofiageo sofiageo self-requested a review April 14, 2026 20:39
Copy link
Copy Markdown
Member

@sofiageo sofiageo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As Jeremy said I must have been unlucky when I tested the branch. It works for me now, but it takes 2 minutes and 40 seconds to build the desktop app for the stable rust cargo.

I can try to test the nightly rust toolchain to see if it improves compilation times but I don't think it's realistic to expect all AUR users to have the nightly rust. With that in mind, let's merge it and we figure out how to make the AUR builds faster later.

@ramkrishna2910
Copy link
Copy Markdown
Contributor

Test drove the app on all common use cases across modalities and the experience was identical to the electron app.
The build took ~3mins for me.

@jeremyfowers jeremyfowers added this pull request to the merge queue Apr 15, 2026
Merged via the queue into main with commit 77220b9 Apr 15, 2026
72 checks passed
@jeremyfowers jeremyfowers deleted the tauri branch April 15, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants