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 -
frpcconnects tofrps(server) running on a public cloud
Options for distributing frpc:
- Tell users to install frp separately — too many support tickets
-
Spawn
frpcvia system PATH lookup — fragile, version drift -
Bundle
frpcbinary 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
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"
]
}
}
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 */ }
_ => {}
}
}
});
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);
}
}
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));
}
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:
- Downloads the new frpc release tarball
- Verifies SHA256
- Atomically replaces the binary
- 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(())
}
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
-
Pin frpc version in
Cargo.tomlrather than downloading at build time — simpler CI -
Use
tauri-plugin-shell's structured sidecar API (newer versions have it) — saves the manual stdout stream loop - 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)