Doc Viewer App
Pattern: Lightweight Tauri wrapper around a pnpm dev server
Doc Viewer App
This recipe covers the architecture of a lightweight Tauri app that wraps a documentation site’s dev server. The Rust backend spawns pnpm dev, waits for the server to become ready, and navigates the webview to the local URL. No frontend framework is used in the Tauri app itself — the entire UI comes from the wrapped dev server.
Architecture Overview
The Two Modes
The app behaves differently in dev and production:
- Dev mode (
cargo tauri dev): Tauri’sbeforeDevCommandstartspnpm dev. The Rust code just points the webview at the server URL. - Production (
cargo tauri build): The Rust code itself findspnpm, spawns it as a child process, waits for readiness, and cleans up on exit.
const PORT: u16 = 32342;
const DEFAULT_PATH: &str = "/";
const IS_DEV: bool = cfg!(debug_assertions);
const PNPM_CMD: &str = "dev";
Finding pnpm
In production, the app needs to find the pnpm binary on the user’s system. A GUI app does not inherit the user’s shell PATH, so you cannot rely on just calling pnpm.
The strategy: check hardcoded well-known paths first, then fall back to which.
fn find_pnpm() -> Option<PathBuf> {
// Check well-known installation paths first
let candidates = [
"/opt/homebrew/bin/pnpm",
"/usr/local/bin/pnpm",
];
for p in &candidates {
let path = PathBuf::from(p);
if path.exists() {
return Some(path);
}
}
// Fallback: ask the system
if let Ok(output) = Command::new("/usr/bin/which").arg("pnpm").output() {
let path_str = String::from_utf8_lossy(&output.stdout)
.trim()
.to_string();
if !path_str.is_empty() {
let path = PathBuf::from(&path_str);
if path.exists() {
return Some(path);
}
}
}
None
}
📝 Note
Hardcoded paths are checked first because which can be unreliable in GUI app contexts. The /usr/bin/which path is used (not just which) because the app’s PATH may not include /usr/bin.
Spawning the Sidecar
The sidecar is spawned in its own process group. This is critical for cleanup — when you need to kill the dev server, you kill the entire process group, not just the parent pnpm process (which itself spawns child processes).
struct Sidecar {
child: Child,
pid: u32,
}
fn spawn_sidecar(pnpm_path: &std::path::Path) -> Sidecar {
let dir = target_dir(); // The directory containing the project to serve
let mut cmd = Command::new(pnpm_path);
cmd.args([PNPM_CMD])
.current_dir(&dir)
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(log_file_clone));
// Create a new process group so we can kill all child processes
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let child = cmd.spawn().expect("Failed to spawn pnpm sidecar");
let pid = child.id();
Sidecar { child, pid }
}
⚠️ Warning
Without process_group(0), killing the pnpm process leaves orphaned Node.js processes still bound to the port. The next launch fails because the port is occupied by ghosts from the previous run.
Killing the Sidecar
Kill the process group (negative PID), not just the process:
fn kill_sidecar(sidecar: &mut Sidecar) {
#[cfg(unix)]
{
if let Ok(pid) = i32::try_from(sidecar.pid) {
if pid > 0 {
// Negative PID signals the entire process group
unsafe { libc::kill(-pid, libc::SIGTERM) };
}
}
}
// Wait briefly for graceful shutdown
thread::sleep(Duration::from_millis(500));
// Escalate if still running
match sidecar.child.try_wait() {
Ok(Some(_)) => {
// Already exited
}
_ => {
let _ = sidecar.child.kill(); // SIGKILL
let _ = sidecar.child.wait(); // Reap
}
}
}
Port Cleanup on Startup
Before spawning a new server, kill anything already listening on the port. This handles the case where a previous app instance crashed without cleaning up:
fn kill_port() {
if let Ok(output) = Command::new("/usr/bin/lsof")
.args(["-ti", &format!(":{PORT}")])
.output()
{
let pids = String::from_utf8_lossy(&output.stdout);
for line in pids.trim().lines() {
if let Ok(pid) = line.trim().parse::<i32>() {
unsafe { libc::kill(pid, libc::SIGTERM) };
}
}
if !pids.trim().is_empty() {
thread::sleep(Duration::from_millis(500));
}
}
}
Readiness Polling
The app polls the server URL until it returns a non-error HTTP status code:
fn wait_for_ready(timeout: Duration) {
let start = Instant::now();
while start.elapsed() < timeout {
let code = Command::new("/usr/bin/curl")
.args([
"-s", "-o", "/dev/null", "-w", "%{http_code}",
&format!("http://localhost:{PORT}/"),
])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "err".to_string());
if code != "000" && code != "err" {
// Server is ready
thread::sleep(Duration::from_secs(1)); // Extra delay for stability
return;
}
thread::sleep(Duration::from_secs(1));
}
// Timeout - proceed anyway and let the user see the error
}
💡 Tip
Use /usr/bin/curl (absolute path) instead of just curl. In a GUI app context, the shell PATH may not be set up, and curl might not be found.
Loading Screen
The window opens immediately with a loading page, then navigates to the server URL once it is ready. This avoids the app appearing frozen during the build process.
// In setup()
if IS_DEV {
// Dev mode: server is already running, point directly to it
let url: tauri::Url = server_url().parse().unwrap();
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
.title("zmod doc")
.inner_size(1200.0, 800.0)
.build()?;
} else {
// Production: show default (bundled) page first
WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("zmod doc")
.inner_size(1200.0, 800.0)
.build()?;
// Then navigate once server is ready (in background thread)
let handle = app.handle().clone();
thread::spawn(move || {
wait_for_ready(Duration::from_secs(120));
if let Some(w) = handle.get_webview_window("main") {
let url: tauri::Url = server_url().parse().unwrap();
let _ = w.navigate(url);
}
});
}
The frontendDist in tauri.conf.json points to a minimal directory with just a loading page:
{
"build": {
"frontendDist": "./frontend"
}
}
<!-- frontend/index.html -->
<!DOCTYPE html>
<html>
<body style="display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:sans-serif">
<p>Loading...</p>
</body>
</html>
Zoom Menu Items
A doc viewer benefits from zoom controls. The app stores the current zoom level in AppState and applies it via JavaScript injection:
struct AppState {
sidecar: Arc<Mutex<Option<Sidecar>>>,
pnpm_path: Option<PathBuf>,
zoom: Mutex<f64>,
}
fn apply_zoom(app_handle: &AppHandle, level: f64) {
let state = app_handle.state::<AppState>();
*state.zoom.lock().unwrap() = level;
if let Some(w) = app_handle.get_webview_window("main") {
let _ = w.eval(&format!("document.body.style.zoom = '{level}'"));
}
}
Menu items for zoom control:
let view_menu = SubmenuBuilder::new(app, "View")
.item(
&MenuItemBuilder::with_id("actual_size", "Actual Size")
.accelerator("CmdOrCtrl+0")
.build(app)?,
)
.item(
&MenuItemBuilder::with_id("zoom_in", "Zoom In")
.accelerator("CmdOrCtrl+=")
.build(app)?,
)
.item(
&MenuItemBuilder::with_id("zoom_out", "Zoom Out")
.accelerator("CmdOrCtrl+-")
.build(app)?,
)
.build()?;
And the handler:
app.on_menu_event(|app_handle, event| match event.id().as_ref() {
"actual_size" => apply_zoom(app_handle, 1.0),
"zoom_in" => {
let state = app_handle.state::<AppState>();
let z = (*state.zoom.lock().unwrap() + 0.1).min(3.0);
apply_zoom(app_handle, z);
}
"zoom_out" => {
let state = app_handle.state::<AppState>();
let z = (*state.zoom.lock().unwrap() - 0.1).max(0.1);
apply_zoom(app_handle, z);
}
_ => {}
});
Process Cleanup on Exit
When the window is destroyed, kill the sidecar:
.run(move |app_handle, event| match &event {
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
..
} => {
if !IS_DEV {
if let Ok(mut g) = sidecar_for_exit.lock() {
if let Some(mut s) = g.take() {
kill_sidecar(&mut s);
}
}
}
app_handle.exit(0);
}
_ => {}
});
📝 Note
The sidecar_for_exit is an Arc<Mutex<Option<Sidecar>>> cloned from AppState before moving into the closure. This is needed because the run() closure captures by move and outlives the setup() closure.
Config File
The corresponding tauri.conf.json:
{
"productName": "zmod doc",
"version": "0.1.0",
"identifier": "com.takazudo.zmod-doc",
"build": {
"frontendDist": "./frontend",
"beforeDevCommand": "cd ../../doc && pnpm dev",
"devUrl": "http://localhost:32342/"
},
"app": {
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [],
"category": "DeveloperTool",
"macOS": {
"minimumSystemVersion": "10.15"
}
}
}
Key points:
windows: []— empty because windows are created programmatically in RustbeforeDevCommandusescd— because the command CWD is the repo root, not the config directoryfrontendDist: "./frontend"— minimal loading page, not the actual content- No
beforeBuildCommand— the production app spawns its own server, it does not embed static assets