DEV Community

ChenXX
ChenXX

Posted on

Bundling a CLI Binary as a Tauri v2 Sidecar: Lessons from Building a Desktop App

When you build a desktop app with Tauri v2, sooner or later you'll hit a question: how do I bundle and manage an external CLI binary inside my app?

Maybe it's ffmpeg for video processing. Maybe it's a database engine. Maybe — as in my case — it's frpc, the reverse-proxy client from the popular frp project.

This post walks through the full lifecycle: bundling, spawning, lifecycle management, and even self-updating the binary at runtime — all from Rust.

1. Declaring the Sidecar

In tauri.conf.json, declare the binary under bundle.externalBin:

{
  "bundle": {
    "externalBin": ["binaries/frpc"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Tauri identifies the target platform by a filename suffix convention. You need to place the correctly-named binary in your project:

Platform Filename
macOS (Apple Silicon) frpc-aarch64-apple-darwin
macOS (Intel) frpc-x86_64-apple-darwin
Windows (x64) frpc-x86_64-pc-windows-msvc.exe

Tauri automatically strips the suffix at runtime and loads the right binary for the current platform.

2. Spawning the Process

Use tauri_plugin_shell to spawn the sidecar:

use tauri_plugin_shell::{ShellExt, process::CommandEvent};

#[tauri::command]
async fn start_frpc(app: tauri::AppHandle) -> Result<(), String> {
    let sidecar = app
        .shell()
        .sidecar("frpc")
        .map_err(|e| e.to_string())?;

    let (mut rx, child) = sidecar
        .args(["-c", "frpc.toml"])
        .spawn()
        .map_err(|e| e.to_string())?;

    // Store the child handle so we can kill it later
    app.state::<std::sync::Mutex<Option<tauri_plugin_shell::process::CommandChild>>>()
        .lock()
        .unwrap()
        .replace(child);

    // Listen to stdout/stderr in a background task
    tauri::async_runtime::spawn(async move {
        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stdout(line) => {
                    // Parse log line, update UI state...
                }
                CommandEvent::Stderr(line) => { /* ... */ }
                CommandEvent::Terminated(_) => {
                    // Process exited — update state machine
                }
                _ => {}
            }
        }
    });

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The key insight: always store the CommandChild handle. You'll need it to kill the process cleanly when the user clicks "Stop".

3. Lifecycle: Don't Trust Optimistic Flags

A subtle trap: spawn() succeeding does not mean the process is actually working. It just means the OS started it. The binary might immediately crash due to a bad config, a missing port, or a network error.

The fix is to derive the "connected" state from real evidence. In my app, after spawning frpc, I poll its admin API endpoint (/api/status) with an exponential backoff: 3s → 6s → 12s → 24s. Only when I get a healthy response do I flip the UI to "Connected".

/// Adaptive health polling: 3 → 6 → 12 → 24 seconds
fn next_interval(prev: Duration) -> Duration {
    (prev * 2).min(Duration::from_secs(24))
}
Enter fullscreen mode Exit fullscreen mode

If no healthy response arrives within 30 seconds, I fall back to an "Error" state — much better than showing a fake "Connected" to the user.

4. Self-Updating the Binary at Runtime

This is the fun part. Users shouldn't have to reinstall the entire app just because frpc shipped a new version. Here's the update flow I implemented:

  1. Fetch the latest release info from GitHub's API
  2. Download the binary to a temp path
  3. Verify the SHA256 checksum
  4. Atomically swap: rename old → .old, rename new → target
  5. Restart the sidecar process
pub async fn update_frpc(target_version: &str) -> Result<(), UpdateError> {
    // 1. Download to temp
    let tmp_path = app_config_dir.join(".frpc.downloading");
    download_binary(&url, &tmp_path).await?;

    // 2. SHA256 verify
    let hash = sha256_file(&tmp_path)?;
    if hash != expected_hash {
        return Err(UpdateError::ChecksumMismatch);
    }

    // 3. Kill current process first
    kill_current_frpc().await;

    // 4. Atomic swap
    let final_path = sidecar_path(&app_config_dir);
    fs::rename(&final_path, format!("{}.old", final_path.display()))
        .ok();  // best-effort cleanup
    fs::rename(&tmp_path, &final_path)?;

    // 5. Restart with new binary
    start_frpc(app).await?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The .old suffix trick lets you roll back if the new binary fails to start.

5. Platform Gotchas

macOS code signing: The sidecar binary must be signed, or Gatekeeper will block it. If you're cross-compiling, you need to sign the binary after downloading it:

codesign --force --sign "Developer ID Application: Your Name" frpc-aarch64-apple-darwin
Enter fullscreen mode Exit fullscreen mode

Windows: Watch out for antivirus false positives. CLI tools that manage network connections tend to trigger heuristics. Digitally signing with an EV certificate helps a lot.

Permissions: On macOS, you may need to request network entitlements in your Info.plist or capabilities. Tauri v2's capability system handles most of this, but double-check your default.json.

Takeaways

  • Store the CommandChild — you'll need it for clean shutdown.
  • Don't trust spawn success — verify the process is actually doing its job.
  • Exponential backoff for health checks keeps your UI responsive.
  • Atomic swap for updates means no half-written binaries.
  • Platform signing is not optional for distribution.

Building a desktop app that manages an external CLI taught me a lot about Rust's process management, async lifecycle, and the care needed for cross-platform distribution. It's more work than shelling out via std::process::Command, but the control you get is worth it.

If you're interested in seeing these patterns in a real app, I'm building MoonProxy — a desktop GUI for frp that uses exactly this sidecar architecture. It's open-source (MIT) and the code is all there.

Happy shipping! 🦀

Top comments (0)