GeminiDesktop GeminiDesktop
Guides

Tauri 2.x Desktop Gotchas After 6 Months: Static CRT, FFmpeg Sidecar, Turbopack Symlinks

Published · By GeminiDesktop Team

Tauri 2.x Desktop Gotchas After 6 Months: Static CRT, FFmpeg Sidecar, Turbopack Symlinks

Bottom line: Tauri 2.x beats Electron on binary size, performance, and security. But its cross-platform promise (especially Windows + macOS in parallel) hides many more traps than the official docs suggest. After 6 months shipping Gemini Desktop, we compiled the 7 most time-consuming gotchas into diagnosis protocols — not a “tried 3 things until one worked” story.

One-liner: 80% of Tauri 2.x gotchas live at the seams between the Rust ecosystem (sherpa-onnx / portable-pty / scap) and the JS ecosystem (Turbopack / tauri-plugin-store), not Tauri itself. Diagnosis protocol > single-point fix.

1. Windows MSVC CRT Static Linking (+crt-static)

The biggest gotcha. On Windows we integrated sherpa-onnx for local ASR. Its C++ deps compile with /MT (static CRT). Most Rust crates (openssl-sys, ring) default to /MD (dynamic CRT). The final link stage throws a cascade of LNK2038: 'RuntimeLibrary' mismatch errors.

We initially tried “1v1 fixing” — find each mismatched crate and force it to /MT. Fail. Reasons:

  • Every cc-rs-built C++ crate has its own compiler flags
  • Each -sys crate (openssl-sys, ring, libsqlite3-sys) writes its build.rs independently
  • Even if you fix one, the next dep bump reverts to /MD

Right answer: global +crt-static. In the project root .cargo/config.toml:

[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]

This forces all Rust code through static CRT. sherpa-onnx’s anchor constraint is satisfied and every other crate is pulled in line. Only cost is binary +2–4 MB, entirely acceptable.

2. CRT Mismatch Diagnosis Protocol

When you see LNK2038 'RuntimeLibrary' mismatch, do not jump into code. Run the protocol:

  1. Enumerate all cc-rs-dependent crates
    cargo tree -e build | grep cc
  2. Enumerate all -sys crates
    cargo tree | grep -E "\-sys"
  3. Find the “immovable constraint” — the crate that hard-requires /MT or /MD (usually ML/audio models or low-level C++ SDKs)
  4. Pick global anchor: if a /MT constraint exists, anchor globally to +crt-static; otherwise keep default /MD
  5. Verify: cargo build --release --target x86_64-pc-windows-msvc clean, dumpbin /dependents shows no external VCRUNTIME140.dll

We ran this protocol three times (sherpa-onnx, libsodium, ffmpeg-next). Each took under 20 minutes to resolve.

3. FFmpeg Sidecar vs PATH

Tauri 2.x recommends bundling FFmpeg as a sidecar binary rather than relying on system PATH. Right direction, hidden trap.

Initial helper we wrote:

fn get_ffmpeg_path() -> PathBuf {
    // WRONG: returns a placeholder during dev
    app_handle.path_resolver().resolve_resource("ffmpeg").unwrap()
}

Problem: resolve_resource returns a placeholder path during tauri dev, not the sidecar path. When you directly Command::new(get_ffmpeg_path()).spawn(), dev accidentally picks up system ffmpeg (appearing to succeed), while release builds fail to find it.

Right answer: route every ffmpeg call through tauri::Command::new_sidecar("ffmpeg") so the runtime handles dev-vs-release resolution:

use tauri::api::process::Command;

pub async fn run_ffmpeg_sidecar(args: Vec<String>) -> Result<String> {
    let (mut rx, _child) = Command::new_sidecar("ffmpeg")?
        .args(args)
        .spawn()?;
    // ... collect output
}

Wrap it as run_ffmpeg_sidecar, and make it the only entry in the whole project. The 7 scattered Command::new(ffmpeg_path) sites were replaced.

Our Tauri frontend is Next.js + Turbopack (a workspace package inside a monorepo). Odd gotcha: Turbopack does not accept symlinks pointing outside the project root.

Concrete case: to share node_modules across worktrees, we tried ln -s /main/repo/node_modules ./node_modules. Next startup errored:

Error: Resolved path is outside of project root

The limit has no escape hatch. Final plan: every worktree must run bun install --frozen-lockfile, no symlink shortcut. Costs ~2–3 GB extra disk per worktree, but avoids cryptic resolver errors.

Nothing to do in Tauri itself, but this gotcha reproduces whenever monorepo + Tauri + Next.js stack together.

5. portable-pty Pinned to 0.8 — 0.9 Regression

We use portable-pty to spawn shells on Windows. After upgrading to 0.9, Windows console output involving backspace and colored escapes crashed and hung the process.

Timeline:

  • portable-pty 0.8.x: stable
  • portable-pty 0.9.0: new Windows ConPTY backend, deadlocks on complex ANSI escape sequences
  • portable-pty 0.9.1/0.9.2: regression unfixed

Our fix: pin to 0.8.1 in Cargo.toml, with a comment:

# Pinned to 0.8.1 due to Windows ConPTY deadlock in 0.9.x
# See: https://github.com/wez/wezterm/issues/XXXX
portable-pty = "=0.8.1"

Lesson: any Windows-PTY-touching dep upgrade requires manual Windows regression. Linux CI passing says nothing about Windows.

6. tauri-plugin-store: Rust-Side Read/Write Strategy

tauri-plugin-store’s Rust API is skewed toward “JS writes the store, Rust calls through the plugin.” But we have Rust-side background threads (sync, scheduled tasks) that constantly read/write settings. Using the plugin’s Rust API requires AppHandle + async plumbing — very heavy.

We sidestepped it: Rust-side reads/writes .settings.json directly, not through tauri-plugin-store:

use serde_json::{Value, from_reader, to_writer_pretty};

pub fn read_settings(path: &Path) -> Result<Value> {
    let file = File::open(path)?;
    Ok(from_reader(BufReader::new(file))?)
}

pub fn write_settings(path: &Path, v: &Value) -> Result<()> {
    let tmp = path.with_extension("json.tmp");
    to_writer_pretty(BufWriter::new(File::create(&tmp)?), v)?;
    fs::rename(&tmp, path)?; // atomic swap
    Ok(())
}

JS keeps using tauri-plugin-store. Both sides read the same file. Writes use atomic rename (tmp → rename → target) to avoid mid-write corruption. The trade-off is accepting that the two sides do not share one abstraction layer, in exchange for much simpler Rust code.

7. scap vs CGDisplayCreateImage: macOS Screen Capture

macOS screen capture options in 2026:

Option Pros Cons
CGDisplayCreateImage (legacy) Simple, sync API Deprecated in macOS 15+, future removal
ScreenCaptureKit (Apple official) Recommended, future-proof Swift-centric, few Rust bindings
scap (Rust crate) Cross-platform (macOS + Windows), actively maintained Windows stability is iffy

Our call: use scap on macOS, defer Windows screen capture altogether. Reasons:

  1. scap’s macOS impl wraps ScreenCaptureKit, the closest-to-official Rust option
  2. scap’s Windows impl depends on Windows.Graphics.Capture, which blackscreens on some GPU + multi-monitor setups
  3. Screen capture is not a P0 feature; it is not worth 2 weeks on a Windows-only path

If you are building a Tauri 2.x desktop app, do not promise “Windows + macOS screen capture” on day 1. scap’s Windows issues are ecosystem issues, not Tauri issues. For more desktop AI product decisions, see Gemini Mac App — the 6 Missing Features.

8. Seven Day-1 Recommendations for New Tauri 2.x Projects

If I started a new Tauri 2.x project today, day 1 would be:

  • Force +crt-static: add target-feature to .cargo/config.toml for Windows target, skip CRT mismatch churn
  • One FFmpeg sidecar entry: all ffmpeg calls go through run_ffmpeg_sidecar, ban Command::new
  • Pin known-regression crates: portable-pty 0.8.1, tauri-plugin-store stable, comment why
  • No symlink node_modules in monorepo: install cleanly in every worktree
  • Do not depend on PATH tools: ffmpeg, yt-dlp, ripgrep all bundled as sidecars
  • Read config in parallel from Rust and JS: Rust reads JSON directly, JS uses tauri-plugin-store
  • Commit to a single screen-capture platform: macOS or Windows, not half-baked on both

Check tauri.app for the latest docs, but the seven points above still hold in April 2026.

FAQ

Q1: Electron or Tauri 2.x?

A: Electron has fewer gotchas but 150+ MB binaries and heavy memory; Tauri 2.x has more gotchas but 10–20 MB binaries and native performance. If your team has Rust experience and the product cares about size/perf, pick Tauri. If it’s only JS/TS background and feature velocity > size, Electron is still safe.

Q2: Does +crt-static blow up the binary?

A: We measured ~20% growth (18 MB → 22 MB). For desktop apps that is negligible, especially next to Electron’s 150+ MB baseline.

Q3: Why not use Tauri’s isolation pattern instead of sidecars?

A: Isolation is a JS-layer security sandbox, orthogonal to sidecars. Sidecars solve “ship external tools inside the app”, not security. You can use both at once.

Q4: Is the portable-pty 0.9 regression reported?

A: Yes, multiple issues exist. Maintainers are stretched thin and ConPTY is nasty. Short term: pin 0.8.1. Long term: watch the alacritty / wezterm teams’ ConPTY progress.

Wrap-Up

Tauri 2.x is a very promising desktop framework, but its “cross-platform” promise means you still have to crash into each platform’s specific traps yourself. This post does not criticize Tauri’s core — the core is stable. The hard parts are the seams between Rust ecosystem (audio, PTY, screen capture) and JS ecosystem (Turbopack, store plugin). Turn each gotcha into a team runbook so you only pay the cost once.