Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Desktop/Mobile — Tauri

These are the steps to set up and run a Crux app as a desktop (and mobile) application using Tauri. Tauri uses a native webview to render the UI, with a Rust backend — making it a natural fit for Crux.

Note

This walk-through assumes you have already added the shared library to your repo, as described in Shared core and types.

Info

Tauri apps have a Rust backend (where the Crux core lives) and a web frontend (React, in this example). Because the core runs directly in the Rust backend process, there is no need for WebAssembly or FFI — the shell calls the core directly and communicates with the frontend via Tauri's event system.

Create a Tauri App

Install the Tauri CLI if you haven't already:

cargo install tauri-cli

Create a new Tauri app. Tauri's init command will scaffold the project structure for you — choose React as the frontend framework.

cargo tauri init

Project structure

A Tauri project has two parts:

  • src-tauri/ — the Rust backend, where the Crux core lives
  • src/ — the web frontend (React + TypeScript in this example)

Backend dependencies

Add the shared library and Tauri to your src-tauri/Cargo.toml:

[package]
name = "counter_tauri"
version = "0.1.0"
authors.workspace = true
repository.workspace = true
edition.workspace = true
license.workspace = true
keywords.workspace = true
rust-version.workspace = true

[lib]
name = "tauri_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }

[dependencies]
shared = { path = "../../shared" }
tauri = { version = "2.10.3", features = [] }

[features]
custom-protocol = ["tauri/custom-protocol"]

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = [
    'cfg(mobile)',
    'cfg(desktop)',
] }

Frontend dependencies

Your package.json should include the Tauri API package for communicating between the frontend and backend:

{
  "name": "tauri",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "tauri": "tauri",
    "postinstall": "mkdir -p dist"
  },
  "dependencies": {
    "@tauri-apps/api": "^2.10.1",
    "react": "^19.2.4",
    "react-dom": "^19.2.4"
  },
  "devDependencies": {
    "@tauri-apps/cli": "^2.10.1",
    "@types/node": "^25.5.0",
    "@types/react": "^19.2.14",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^6.0.1",
    "typescript": "^5.9.3",
    "vite": "^8.0.0"
  },
  "packageManager": "pnpm@9.6.0+sha512.38dc6fba8dba35b39340b9700112c2fe1e12f10b17134715a4aa98ccf7bb035e76fd981cf0bb384dfa98f8d6af5481c2bef2f4266a24bfa20c34eb7147ce0b5e"
}

The Rust backend

The Rust backend is where the Crux core runs. We create a static Core instance and expose Tauri commands that forward events to the core. When the core requests a Render effect, we emit a Tauri event to the frontend with the updated view model.

use shared::{Core, Counter, Effect, Event};
use std::sync::{Arc, LazyLock};
use tauri::Emitter;

static CORE: LazyLock<Arc<Core<Counter>>> = LazyLock::new(|| Arc::new(Core::new()));

fn handle_event(event: Event, core: &Arc<Core<Counter>>, app: &tauri::AppHandle) {
    for effect in core.process_event(event) {
        process_effect(effect, core, app);
    }
}

fn process_effect(effect: Effect, core: &Arc<Core<Counter>>, app: &tauri::AppHandle) {
    match effect {
        Effect::Render(_) => {
            let view = core.view();
            let _ = app.emit("render", view);
        }
    }
}

#[tauri::command]
async fn increment(app_handle: tauri::AppHandle) {
    handle_event(Event::Increment, &CORE, &app_handle);
}

#[tauri::command]
async fn decrement(app_handle: tauri::AppHandle) {
    handle_event(Event::Decrement, &CORE, &app_handle);
}

#[tauri::command]
async fn reset(app_handle: tauri::AppHandle) {
    handle_event(Event::Reset, &CORE, &app_handle);
}

/// The main entry point for Tauri
/// # Panics
/// If the Tauri application fails to run.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![increment, decrement, reset])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

A few things to note:

  • The Core is stored in a LazyLock<Arc<...>> so it can be shared across Tauri command handlers.
  • Each user action (increment, decrement, reset) is a separate Tauri command that sends the corresponding event to the core.
  • The Render effect is handled by calling app.emit("render", view), which sends the serialized ViewModel to the frontend as a Tauri event.
  • Because the core is running directly in Rust, there is no serialization boundary between the shell and the core — we call core.process_event() directly.

The React frontend

The frontend listens for render events from the backend and updates the UI. User interactions invoke Tauri commands, which run in the Rust backend.

import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";

type ViewModel = {
  count: string;
};

const initialState: ViewModel = {
  count: "",
};

function App() {
  const [view, setView] = useState(initialState);

  useEffect(() => {
    let unlistenToRender: UnlistenFn;

    listen<ViewModel>("render", (event) => {
      setView(event.payload);
    }).then((unlisten) => {
      unlistenToRender = unlisten;
    });

    // trigger initial render
    invoke("reset");

    return () => {
      unlistenToRender?.();
    };
  }, []);

  return (
    <main>
      <section className="section has-text-centered">
        <p className="title">Crux Counter Example</p>
        <p className="is-size-5">Rust Core, Rust Shell (Tauri + React)</p>
      </section>
      <section className="container has-text-centered">
        <p className="is-size-5">{view.count}</p>
        <div className="buttons section is-centered">
          <button
            className="button is-primary is-danger"
            onClick={() => invoke("reset")}
          >
            {"Reset"}
          </button>
          <button
            className="button is-primary is-success"
            onClick={() => invoke("increment")}
          >
            {"Increment"}
          </button>
          <button
            className="button is-primary is-warning"
            onClick={() => invoke("decrement")}
          >
            {"Decrement"}
          </button>
        </div>
      </section>
    </main>
  );
}

export default App;

The frontend is straightforward:

  • On mount, we call listen("render", ...) to receive view model updates from the backend, and invoke reset to trigger an initial render.
  • Button clicks call invoke("increment"), invoke("decrement"), etc. — these are the Tauri commands defined in our Rust backend.
  • There is no serialization code in the frontend — Tauri handles the serialization of the ViewModel struct automatically.

Build and run

cargo tauri dev

Success

Your app should look like this:

simple counter app