zudo-tauri-wisdom
GitHub repository

Type to search...

to open search from anywhere

Doc Viewer App

An ownership-safe Tauri wrapper around a local documentation server

The host starts an owned local server, keeps a bundled loading page visible until the destination is ready, and navigates only after the current launch generation is accepted. Prefer a bundled native sidecar; discovering the user's Node/pnpm is a fallback for tools that really require JavaScript at runtime.

Architecture Overview

sequenceDiagram participant UI participant Coordinator participant Worker participant Sidecar UI->>Coordinator: Launch / Retry Coordinator->>Coordinator: Advance generation; take old resources Coordinator->>Worker: Stop old group; prepare new resources Worker->>Sidecar: Spawn owned process group on loopback Worker->>Worker: Probe route, marker, module entries Worker->>Coordinator: Publish generation + owned resources alt Current generation and not shutting down Coordinator->>UI: Navigate to effective URL else Superseded or shutdown Coordinator->>Worker: Dispose unpublished resources end

The Two Modes

In development, beforeDevCommand may own the development server; runtime cleanup must not kill that externally owned process. A packaged app stages its dependencies at build time and owns its own sidecar. Resolve the writable workspace before launching: bundled Resources are input, not a place for generated output.

Spawning the Sidecar

Use the lifecycle coordinator as the single owner of the child, process group, watcher and effective port. On Unix use process_group(0) and record the returned child's PGID before publishing it. Serialize restarts; reject and dispose late worker results after Retry or shutdown. Redirect both output streams into app-writable logs.

Port Cleanup on Startup

There is no port-owner kill step. Strict mode reports an occupied preferred port; an explicit fallback policy selects a free loopback port. Pass that effective port through spawn, probe, navigation and any capability policy. A bind preflight is racy; retry only a confirmed bind race. Never terminate a service found with lsof.

Readiness Polling

Use an in-process HTTP client on a background worker. This bounded example disables proxies and redirects, constrains requests to IPv4 loopback, and rejects oversized responses. A production poller can reuse its client. reqwest is an explicit application dependency here; it is not supplied by Tauri.

// Cargo.toml: reqwest = { version = "0.12", default-features = false, features = ["blocking"] }
use std::{io::{self, Read}, time::Duration};

fn probe_route(port: u16, path: &str) -> Result<(u16, String), Box<dyn std::error::Error>> {
    if !path.starts_with('/') || path.starts_with("//") || path.contains(['\r', '\n']) {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid route").into());
    }
    let client = reqwest::blocking::Client::builder()
        .no_proxy()
        .redirect(reqwest::redirect::Policy::none())
        .connect_timeout(Duration::from_millis(200))
        .timeout(Duration::from_secs(1))
        .build()?;
    let response = client.get(format!("http://127.0.0.1:{port}{path}")).send()?;
    let status = response.status().as_u16();
    const LIMIT: u64 = 2 * 1024 * 1024;
    let mut bytes = Vec::new();
    response.take(LIMIT + 1).read_to_end(&mut bytes)?;
    if bytes.len() as u64 > LIMIT {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "response too large").into());
    }
    Ok((status, String::from_utf8(bytes)?))
}

HTTP success alone is insufficient. Define a server/host contract:

  1. Probe the actual destination, such as /docs/, and require status 200.

  2. Require a per-launch marker generated by the host and emitted by the current server render. A previous build's marker classifies as StaleContent.

  3. Parse HTML with an HTML parser, resolve each required <script type="module" src> against the route URL, and require the same scheme, host and effective port before requesting it. Reject cross-origin redirects. Check status 200 without downloading the full JavaScript body. If island markers exist but no expected module entry is present, keep waiting.

  4. Distinguish HttpUnavailable, StaleContent and Ready; report the last state on timeout. Keep an overall deadline, request/asset-count limits and cancellation checks between asset probes. Start with a 50 ms tick, not a subprocess curl per tick or an extra fixed sleep after success.

This establishes the agreed asset contract, not proof that client JavaScript executed successfully. Keep browser tests for hydration. Each tick also checks child death and generation; ReadyResult includes Superseded, which never navigates or overwrites newer UI.

Loading Screen

Create the main webview with WebviewUrl::default() backed by the bundled frontendDist. Keep it visible on timeout or child exit and show a log path plus Retry. Only the coordinator's accepted Ready result may navigate to the effective route. Do not unconditionally navigate after a waiting function returns.

DocumentStart state

For appearance or other state needed before page scripts run, use initialization_script and serialize values as JSON rather than interpolating raw user strings:

let initial_state = serde_json::to_string(&serde_json::json!({ "appearance": "dark" }))?;
let init = format!("window.__APP_INITIAL_STATE__ = {initial_state};");
let window = tauri::WebviewWindowBuilder::new(
    app, "main", tauri::WebviewUrl::default(),
)
.initialization_script(init)
.build()?;

The page's earliest script can consume that state. At DocumentStart, document.body may not exist; do not depend on it. The script applies to future navigations, so keep it non-secret and constrain the webview's navigation destinations. See Tauri's initialization-script implementation.

Loopback Capabilities and CSP

Bind only to 127.0.0.1. Keep privileged Settings and loading pages local, with separate narrowly scoped capabilities. If the docs page needs only event listening, its remote capability can be:

{
  "identifier": "docs-main",
  "windows": ["main"],
  "local": false,
  "remote": { "urls": ["http://127.0.0.1:32342/*"] },
  "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
}

This fixed-port example is valid only for that effective port. In fallback mode, create an exact runtime capability for the chosen origin before navigation, or keep remote IPC disabled. Do not replace it with all ports/all windows. Capability grants combine across matching files; audit the whole set. Custom commands need their own permission manifest and caller checks; plugin permissions do not automatically restrict every registered command.

A starting policy for bundled pages (merge into your config and provide the named capabilities):

{
  "app": {
    "windows": [],
    "security": {
      "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'",
      "capabilities": ["loading", "docs-main", "settings"]
    }
  }
}

Load loading-page JS/CSS from separate bundled files for this policy. Adapt only required sources. Tauri's configured CSP does not automatically become the HTTP sidecar's response policy: the server must send its own CSP header for the remote document, including only its required scripts/styles/assets and IPC endpoints if enabled. Reject navigation to unrelated origins and open external links outside the privileged webview. See capabilities and CSP.

Zoom Menu Items

A doc viewer benefits from zoom controls. The app stores the current zoom level in AppState and applies it to the webview with set_zoom:

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.set_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);
    }
    _ => {}
});

document.body.style.zoom is document-scoped and resets on navigation. WebviewWindow::set_zoom uses the native webview API; verify persistence on your target backend and reapply stored zoom on page-load completion if needed. Clamp the stored value, and test both full navigations and new windows. See the implementation.

Process Cleanup on Exit

Follow every exit path and auxiliary windows. Closing Settings must not exit the app. Take the owned-resource slot once, cancel probes/watchers, stop the whole group, and prevent any later worker publication. Test main-window close, app quit and restart separately on the native target.

Cold-Install Bootstrap in the Sidecar

For a distributed native sidecar, install with pnpm install --frozen-lockfile in a controlled setup/build step, stage the target binary and runtime assets, and validate the staged tree before packaging. Do not run a network package install on first launch. Test the packaged app with Node/pnpm absent and networking disabled. See native sidecar binaries for wrapper and plugin traps.

Runtime Node Fallback

When the wrapped tool actually requires Node, document supported versions and validate Node and pnpm before spawning. A discovered shim is not proof that its runtime works; report a failed version probe clearly. Development-only cold-install workflows must be opt-in and fail visibly rather than silently downloading dependencies in a shipped app.

Finding pnpm

In this fallback mode, 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 (including version-manager shims), then fall back to which.

fn find_pnpm() -> Option<PathBuf> {
    // Check well-known installation paths first
    let home = std::env::var("HOME").ok()?;
    let candidates = [
        "/opt/homebrew/bin/pnpm".to_string(),  // Apple Silicon Homebrew
        "/usr/local/bin/pnpm".to_string(),     // Intel Homebrew
        format!("{home}/.volta/bin/pnpm"),     // Volta shim
    ];
    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. Include $HOME/.volta/bin/pnpm in the hardcoded list when your users commonly pin Node via Volta — Finder launches do not see Volta's shim injection.

Revision History

CreatedUpdated