DEV Community

ChenXX
ChenXX

Posted on

Bundling a CLI Binary as a Tauri v2 Sidecar: Architecture and Lessons from Building MoonProxy

When you build a desktop app that needs to ship a CLI binary alongside it, the Tauri v2 sidecar mechanism is the cleanest solution. This post is a deep-dive into how MoonProxy (an open-source FRP GUI client for macOS & Windows) uses it to bundle the frpc binary so users never have to install it separately.

MoonProxy v1.2.0+ — Tauri v2 + Vue 3 + Rust, MIT licensed, moonproxy.app

Why sidecar?

A FRP client is intrinsically a client-server tool:

  • The user runs frpc (client) on their machine
  • frpc connects to frps (server) running on a public cloud

Options for distributing frpc:

  1. Tell users to install frp separately — too many support tickets
  2. Spawn frpc via system PATH lookup — fragile, version drift
  3. Bundle frpc binary inside the app — ✅ best UX

Tauri v2's sidecar feature lets you ship a binary that the app spawns as a child process, with the same lifecycle management as the app itself.

Step 1 — Naming convention

Tauri identifies sidecar binaries by file-name suffix per target platform:

Platform File name
macOS Apple Silicon frpc-aarch64-apple-darwin
macOS Intel frpc-x86_64-apple-darwin
Windows x64 frpc-x86_64-pc-windows-msvc.exe
Windows ARM64 frpc-aarch64-pc-windows-msvc.exe

The base name (frpc) is what you reference from tauri.conf.json.

Step 2 — Place binaries in src-tauri/binaries/

Tauri v2 looks for sidecar binaries in src-tauri/binaries/ at compile time and embeds them into the app bundle.

src-tauri/
  binaries/
    frpc-aarch64-apple-darwin
    frpc-x86_64-apple-darwin
    frpc-x86_64-pc-windows-msvc.exe
    frpc-aarch64-pc-windows-msvc.exe
Enter fullscreen mode Exit fullscreen mode

A sync-frpc.sh script downloads the matching frpc release and renames it to the above pattern.

Step 3 — Declare in tauri.conf.json

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

Note: Tauri strips the platform suffix automatically; you only write the base name (frpc).

Step 4 — Spawn the sidecar from Rust

Tauri exposes the tauri-plugin-shell for command-based sidecar execution. In our case we use a Rust wrapper that gives us stdout streaming + log buffering + restart on failure:

use tauri_plugin_shell::ShellExt;

let sidecar_command = app.shell()
    .sidecar("frpc")
    .expect("failed to create sidecar")
    .args(["-c", "/path/to/frpc.toml"]);

let (mut rx, mut child) = sidecar_command.spawn().expect("failed to spawn");

// Stream stdout → in-memory ring buffer + log UI
tauri::async_runtime::spawn(async move {
    use tauri_plugin_shell::process::CommandEvent;
    while let Some(event) = rx.recv().await {
        match event {
            CommandEvent::Stdout(line) => { /* push to log ring buffer */ }
            CommandEvent::Stderr(line) => { /* push to log ring buffer */ }
            CommandEvent::Terminated(payload) => { /* mark exit */ }
            _ => {}
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

Step 5 — Log ring buffer (for the UI)

The Vue UI shows a real-time log feed. We don't want unbounded memory growth, so we use a RingBuffer<String> with a 2000-line capacity in frpc_state.rs:

pub struct LogRing {
    inner: Arc<Mutex<VecDeque<String>>>,
    capacity: usize,
}

impl LogRing {
    pub fn push(&self, line: String) {
        let mut q = self.inner.lock().unwrap();
        if q.len() >= self.capacity {
            q.pop_front();
        }
        q.push_back(line);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 6 — Auto-restart with backoff

If the frpc process dies (network glitch, server reboot), we want to retry with exponential backoff. State machine in frpc_state.rs:

enum ConnState {
    Stopped,
    Connecting { attempt: u32 },
    Connected { since: Instant },
    Error { reason: String, next_retry: Instant },
}

fn schedule_retry(&mut self) {
    let backoff = 2u64.saturating_pow(self.attempt.min(6));  // 1, 2, 4, 8, 16, 32, 64s
    self.state = ConnState::Connecting { attempt: self.attempt + 1 };
    self.timer = Some(Instant::now() + Duration::from_secs(backoff));
}
Enter fullscreen mode Exit fullscreen mode

Step 7 — Self-update of the sidecar binary

When upstream frpc releases a new version, we want users to get it without reinstalling the app. We ship an updater that:

  1. Downloads the new frpc release tarball
  2. Verifies SHA256
  3. Atomically replaces the binary
  4. Restarts frpc
async fn update_frpc(app: AppHandle, new_version: &str) -> Result<()> {
    let url = format!("https://github.com/fatedier/frp/releases/download/v{}/frp_{}_darwin_amd64.tar.gz", new_version, new_version);
    let tmp_path = app.path().app_config_dir()?.join("frpc.new");
    download_and_verify(&url, &tmp_path, &expected_sha).await?;

    // atomic rename
    let final_path = app.path().app_config_dir()?.join("frpc");
    std::fs::rename(&tmp_path, &final_path)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The app itself has a separate updater path (Tauri's built-in tauri-plugin-updater).

Gotchas we hit

1. macOS code signing & Gatekeeper

Unsigned binaries downloaded at runtime trigger Gatekeeper on first launch. Solution: ship the binary inside the .app bundle (already code-signed with the app), not downloaded at runtime for the initial version. Runtime updates still work because the new binary inherits the parent app's permissions (mostly).

2. Windows Defender false positive

frpc is sometimes flagged because of its network behavior. We:

  • Document the SHA256 + source URL in the update flow
  • Provide a "report false positive" link in the UI
  • Sign the binary (planned for v1.3.0)

3. Architecture mismatch on Apple Silicon

A common bug: user is on M1 Mac but the app shipped the Intel frpc. With Tauri v2's platform-suffix naming, this is solved at bundle time, but you need to make sure your build matrix covers all 4 platform suffixes (we use GitHub Actions matrix builds).

What I'd do differently

  1. Pin frpc version in Cargo.toml rather than downloading at build time — simpler CI
  2. Use tauri-plugin-shell's structured sidecar API (newer versions have it) — saves the manual stdout stream loop
  3. Expose the sidecar process to the JS frontend via IPC — we do this for status, but logs are still polled; streaming via Tauri events would be smoother

Code links

  • MoonProxy repository: https://github.com/MoonProxyHQ/moonproxy-desktop
  • Sidecar wrapper: src-tauri/src/process.rs
  • Log ring buffer: src-tauri/src/frpc_state.rs
  • Auto-restart: src-tauri/src/frpc_state.rs::schedule_retry
  • Self-update: src-tauri/src/frpc_update.rs
  • Sync script: scripts/sync-frpc.sh

Try it

If you're building a desktop app that needs to bundle a CLI tool, I hope this deep-dive saves you some time. Tauri sidecar is a powerful pattern — once you have the boilerplate right, shipping a "runs offline, talks to my server" app becomes tractable for a small team.

Top comments (0)