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"]}}
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(())
}
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))
}
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:
- Fetch the latest release info from GitHub's API
- Download the binary to a temp path
- Verify the SHA256 checksum
-
Atomically swap: rename old →
.old, rename new → target - 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(())
}
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
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! 🦀