Sidecars
If we need to embed external binaries into our program, we can create a sidecar configuration to bundle the binaries with the application. This embedded binary is referred to as a sidecar. It is important to note that compiled binaries are only compatible with the architecture it is compiled for.
Creating a sidecar eliminates the need for users to download additional dependencies (e.g. Python). However, developers still need to download said dependencies to compile the binary.
In addition, sidecars allows us to easily run the binaries with bun tauri dev
without needing to run it separately.
Currently, we are using this for FPV Camera.
To learn more about sidecars, refer to the official Tauri documentation.
Spawning a Sidecar (Python)
Section titled “Spawning a Sidecar (Python)”Our current implementation of sidecar spawning is only for .py
files. Thus, it will likely not work for other file types without modification to the spawning system.
This guide will assume you already have Python/pip installed.
Initialization
Section titled “Initialization”Place the .py
file that you want to compile into a sidecar into the src-tauri
.
As preparation, we want to add this listener for the file you want to turn into a binary. That way if you close all of the windows of the GCS, the Flask server will be signaled to shutdown.
import threadingimport sysimport timeimport os
# Shuts down server if windows are manually closed opposed to CTRL+Cdef listen_for_shutdown(): for line in sys.stdin: if line.strip() == "sidecar shutdown": print("[flask] Shutdown command received.") cap.release() os._exit(0)
Configuration
Section titled “Configuration”We need to allow the system shell to spawn child processes (in this case, sidecars).
"permissions": [ ... { "identifier": "shell:allow-spawn", "allow": [ { "name": "binaries/[name of sidecar]", "sidecar": true } ] }]
Then specify the sidecars that should be bundled with the application during the build process.
"bundle": { ... ], "externalBin": ["binaries/[name of sidecar]"]},
Sidecar Process Management
Section titled “Sidecar Process Management”These imports will be necessary.
use std::sync::{Arc, Mutex};use tauri::{Emitter, Manager, RunEvent};use tauri_plugin_shell::process::{CommandChild, CommandEvent};use tauri_plugin_shell::ShellExt;
We have a large function to spawn the sidecar. We will break down each segment of the function.
This segment checks if a sidecar is already running by checking the application state.
fn spawn_sidecar(app_handle: tauri::AppHandle) -> Result<(), String> { // Check if a sidecar process already exists if let Some(state) = app_handle.try_state::<Arc<Mutex<Option<CommandChild>>>>() { let child_process = state.lock().unwrap(); if child_process.is_some() { println!("[tauri] Sidecar is already running. Skipping spawn."); return Ok(()); } } ...}
This segment spawns the sidecar via the Tauri shell API. A receiver rx
is passed in to listen for events from the sidecar, and a CommandChild
child
is also passed in to allow us to write to its stdin, essentially letting us control the process (e.g. kill the sidecar process). The process is then stored in the app state, just as a means for us to access the process.
fn spawn_sidecar(app_handle: tauri::AppHandle) -> Result<(), String> { ... // Spawn sidecar (sidecar function only expects the filename, not the whole path configured in externalBin) let sidecar_command = app_handle .shell() .sidecar("[name of .py file]") .map_err(|e| e.to_string())?; let (mut rx, child) = sidecar_command.spawn().map_err(|e| e.to_string())?;
// Store the child process in the app state if let Some(state) = app_handle.try_state::<Arc<Mutex<Option<CommandChild>>>>() { *state.lock().unwrap() = Some(child); } else { return Err("Failed to access app state".to_string()); } ...}
Lastly, we use Tauri’s asynchronous runtime to continously listen to the receiver rx
for events. In response, we handle the Stdout
(standard output stream) and Stderr
(standard error) events. The data from the Stdout
events will be emitted to the frontend via emit()
. The data from the Stderr
events will be converted into a string and will also be emitted to the frontend but as an error string.
fn spawn_sidecar(app_handle: tauri::AppHandle) -> Result<(), String> { ... // Spawn an async task to handle sidecar communication tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes); println!("Sidecar stdout: {}", line);
// Emit the line to the frontend app_handle .emit("sidecar-stdout", line.to_string()) .expect("Failed to emit sidecar stdout event"); }
CommandEvent::Stderr(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes); eprintln!("Sidecar stderr: {}", line); // Emit the error line to the frontend app_handle .emit("sidecar-stderr", line.to_string()) .expect("Failed to emit sidecar stderr event"); } _ => {} } } });
Ok(())}
Commands
Section titled “Commands”Starting and shutting down the sidecar can be done via these two Tauri commands. These commands can be called from frontend.
#[tauri::command]fn start_sidecar(app_handle: tauri::AppHandle) -> Result<String, String> { println!("[tauri] Received command to start sidecar."); spawn_sidecar(app_handle)?; Ok("Sidecar spawned and monitoring started.".to_string())}
#[tauri::command]fn shutdown_sidecar(app_handle: tauri::AppHandle) -> Result<String, String> { println!("[tauri] Received command to shutdown sidecar."); // Access the sidecar process state if let Some(state) = app_handle.try_state::<Arc<Mutex<Option<CommandChild>>>>() { let mut child_process = state .lock() .map_err(|_| "[tauri] Failed to acquire lock on sidecar process.")?;
if let Some(mut process) = child_process.take() { let command = "sidecar shutdown\n"; // Add newline to signal the end of the command
// Attempt to write the command to the sidecar's stdin if let Err(err) = process.write(command.as_bytes()) { println!("[tauri] Failed to write to sidecar stdin: {}", err);
// Restore the process reference if shutdown fails *child_process = Some(process); return Err(format!("Failed to write to sidecar stdin: {}", err)); }
println!("[tauri] Sent 'sidecar shutdown' command to sidecar."); Ok("'sidecar shutdown' command sent.".to_string()) } else { println!("[tauri] No active sidecar process to shutdown."); Err("No active sidecar process to shutdown.".to_string()) } } else { Err("Sidecar process state not found.".to_string()) }}
Building
Section titled “Building”The last thing we need to do in main.rs
is run the sidecar upon building the application.
Using the Tauri’s builder setup function, we can spawn the sidecar process alongside building the application. In the application’s event loop, upon detecting an exit event, the following will happen:
- The sidecar process will be retrieved from the state.
- A shutdown signal is written to the sidecar’s
stdin
. - Force kill the sidecar process if it hasn’t been terminated yet.
- (On Windows) A system command is ran to kill any still running sidecar process.
#[tokio::main]async fn main() { ... tauri::Builder::default() ... .setup(|app| { // Store the initial sidecar process in the app state app.manage(Arc::new(Mutex::new(None::<CommandChild>))); // Clone the app handle for use elsewhere let app_handle = app.handle(); // Spawn the Python sidecar on startup println!("[tauri] Creating sidecar..."); spawn_opencv_sidecar(app_handle.clone()).ok(); println!("[tauri] Sidecar spawned and monitoring started."); Ok(()) }) // Register the shutdown_server command .invoke_handler(tauri::generate_handler![start_sidecar, shutdown_sidecar,]) .invoke_handler(router.into_handler()) .build(tauri::generate_context!()) .expect("Error while running tauri application") .run(|app_handle, event| match event { // Ensure the Python sidecar is killed when the app is closed RunEvent::ExitRequested { .. } => { if let Some(child_process) = app_handle.try_state::<Arc<Mutex<Option<CommandChild>>>>() { if let Ok(mut child) = child_process.lock() { if let Some(process) = child.as_mut() { // Send msg via stdin to sidecar where it self terminates let command = "sidecar shutdown\n"; let buf: &[u8] = command.as_bytes(); let _ = process.write(buf);
// Force kill the process after a short delay to ensure cleanup std::thread::sleep(std::time::Duration::from_millis(500)); if let Some(process) = child.take() { let _ = process.kill(); }
// TODO: This is kind of messy, find a better way to clear, preferably cross platform // Additional cleanup for Windows #[cfg(target_os = "windows")] { use std::process::Command; let _ = Command::new("taskkill") .args(["/F", "/IM", "opencv.exe"]) .output(); }
println!("[tauri] Sidecar closed."); } } } } _ => {} }); }
As commented toward the bottom, we need to figure out a better solution to cleanup the sidecar when killing the process.
Compiling
Section titled “Compiling”On the developer end, we still need to manually compile the sidecar.
-
Install dependencies.
pip install flaskpip pyinstaller -
Use pyinstaller to compile your
.py
file into a sidecar.pyinstaller --onefile .\src-tauri\[name of .py file] --distpath .\src-tauri\binaries\ -
Ensure the sidecar is in the
dist
folder. Move the sidecar intobinaries
folder (if there is already a file, replace it). -
Run the Node.js script to rename the sidecar file, as you must add your architecture to the file name.
bun run target:tripleRefer to the next section for more details on what this script is.
Now the sidecar should run with bun tauri dev
. It takes time for it to spin up and once it does, you may need to refresh the window.
Target-Triple Script
Section titled “Target-Triple Script”To make sure the sidecar can run on your architecture, we have a Node.js script that tacks on your architecture onto the sidecar file’s name.
import { execSync } from 'child_process';import fs from 'fs';
const extension = process.platform === 'win32' ? '.exe' : '';
const rustInfo = execSync('rustc -vV');const targetTriple = /host: (\S+)/g.exec(rustInfo)[1];if (!targetTriple) { console.error('Failed to determine platform target triple');}fs.renameSync(`src-tauri/binaries/opencv${extension}`, `src-tauri/binaries/opencv-${targetTriple}${extension}`);
execSync('rustc -vV')
will use the Node.js child_process
module to run rustc -vV
in the terminal, which details the Rust compiler’s version info, including the host target (target triple). The regex /host: (\S+)/g.exec(rustInfo)[1]
is then run to find the line after host:
. An example of the result would be x86_64-pc-windows-msvc
, which gets appened to the end of the sidecar’s filename.
.gitignore
Section titled “.gitignore”Since sidecars will not work on every architecture unless it is compiled for each, we should to add the binaries
directory to .gitignore
. This will also help prevent bloat in the repository.