Files
mizan/backends/mizan-tauri

mizan-tauri

Tauri backend adapter for the Mizan protocol. One plugin call on the Rust side. #[mizan::client] on async functions. Typed React client generated. Invalidation automatic — same protocol surface as mizan-fastapi / mizan-django / mizan-rust-axum, routed through Tauri's IPC instead of HTTP.

Scope

mizan-tauri targets the AFI-common subset — RPC dispatch, context bundling, server-driven invalidation/merge. The transport channel is Tauri's invoke(); the dispatch table is the linkme-backed FUNCTIONS slice from mizan-core. No HTTP server is involved — the Tauri runtime handles message framing, the plugin handles dispatch.

Forms / SSR / Channels are out of scope (those are Django-side primitives). Tauri apps using mizan-tauri get RPC + context bundling + invalidation, nothing more.

Install

# src-tauri/Cargo.toml
[dependencies]
tauri = "2"
mizan-core = { path = "../../mizan/cores/mizan-rust" }
mizan-tauri = { path = "../../mizan/backends/mizan-tauri" }
serde = { version = "1", features = ["derive"] }
// package.json
{
    "dependencies": {
        "@mizan/base": "file:../mizan/frontends/mizan-base",
        "@mizan/tauri-transport": "file:../mizan/frontends/mizan-tauri-transport",
        "@tauri-apps/api": "^2"
    }
}

Setup — Rust

Install the plugin on the Tauri builder. The plugin registers a single command (plugin:mizan|mizan_invoke) that routes call/fetch envelopes through the function registry. No per-function #[tauri::command] is needed; the macro-emitted FunctionSpec IS the dispatch table.

// src-tauri/src/lib.rs
mod commands;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(mizan_tauri::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

commands must be reachable from the binary's link graph — mod commands; works (private mod stays linked because lib.rs references it through file inclusion). If a separate binary (e.g. the IR-export bin below) also needs to see the registrations, mark it pub mod commands; so the integration-test / sibling-binary path can force-link.

Define server functions

// src-tauri/src/commands.rs
use mizan_core::{self as mizan, MizanError, RequestHandle};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, mizan_core::Mizan)]
pub struct Greeting {
    pub message: String,
}

#[mizan::client]
pub async fn greet(_req: &RequestHandle<'_>, name: String) -> Greeting {
    Greeting { message: format!("hello, {name}") }
}

// Result<T, MizanError> is supported when the function can fail; the
// dispatch wrapper `?`-unwraps it so server-side errors surface as the
// protocol's standard {code, message, details?} envelope.
#[mizan::client]
pub async fn read_file(
    _req: &RequestHandle<'_>,
    path: String,
) -> Result<Greeting, MizanError> {
    let body = std::fs::read_to_string(&path)
        .map_err(|e| MizanError::NotFound(e.to_string()))?;
    Ok(Greeting { message: body })
}

#[mizan::client] parameters mirror the other backends — context = …, affects = …, merge = …, private. See mizan-rust-axum's README for the full set.

App-state access

The first parameter is req: &RequestHandle<'_> — the same handle the HTTP adapter threads through. Inside a Tauri-mounted plugin, the handle wraps tauri::AppHandle, so user functions can downcast for access to Tauri's managed-state container or event emission:

#[mizan::client]
pub async fn store_value(req: &RequestHandle<'_>, key: String) -> Greeting {
    let app = req.downcast::<tauri::AppHandle>()
        .expect("Tauri AppHandle threaded by mizan-tauri");
    // app.state::<MyState>(), app.emit(...), etc.
    Greeting { message: format!("stored {key}") }
}

Stateless functions ignore the handle (_req: &RequestHandle<'_>).

IR export binary

mizan-generate needs the consumer crate's IR. Add a small bin that references each #[mizan::client] function (so linkme keeps the distributed slice's entries) and prints mizan_core::build_ir():

// src-tauri/src/bin/emit_mizan_ir.rs
//
// Cargo.toml adds:
//   [[bin]]
//   name = "emit-mizan-ir"
//   path = "src/bin/emit_mizan_ir.rs"
//
// linkme only collects from translation units that survive
// dead-code elimination; this fn names one item per file carrying
// #[derive(Mizan)] / #[mizan::client] registrations so the linker
// keeps them in the final binary.

#[allow(dead_code)]
fn _force_link() {
    use my_app_lib::commands;
    let _ = commands::greet;
    let _ = commands::read_file;
    // ... one per #[mizan::client] function
}

fn main() {
    _force_link();
    print!("{}", mizan_core::build_ir());
}

Generate the frontend

# mizan.toml at the project root
project_id = "my-tauri-app"
output = "src/api"
targets = ["react"]

[source.rust]
manifest_path = "src-tauri/Cargo.toml"
bin = "emit-mizan-ir"

# Optional — author the Rust types from Pydantic models via decoru.
# Omit this block for pure-Rust usage.
[source.rust.pydantic]
module = "my_app.schema"
output = "src-tauri/src/schema.rs"
command = ["uv", "run", "python"]    # any python with `decoru` importable
header = """\
// AUTO-GENERATED by mizan-generate (source.rust.pydantic step).
// Source of truth: my_app/schema.py.
// DO NOT EDIT BY HAND. Regenerate with: `mizan-generate`

use serde::{Deserialize, Serialize};

"""
mizan-generate --config mizan.toml

The Pydantic pre-step auto-discovers BaseModel subclasses AND Enum subclasses declared in the named module; decoru emits the structs, and a small inline emitter renders enums (PascalCase variants from Python member names, #[serde(rename_all = "snake_case")], #[default] on the last variant so decoru's impl Default keeps compiling).

The Rust step then runs cargo run --bin emit-mizan-ir, parses the emitted KDL, and dispatches the configured targets to their emitters (stage1 → typed callXxx/fetchXxx; react<MizanContext> + per-context providers + use{Hook}() hooks).

Setup — TS

// src/main.tsx
import { configure } from "@mizan/base";
import { tauriTransport } from "@mizan/tauri-transport";

// Route every mizanCall / mizanFetch through Tauri's IPC. Must run
// before any generated callXxx() executes — top-level at the module
// entry is the safe place.
configure({ transport: tauriTransport() });
// any component
import { callGreet } from "@/api";

const greeting = await callGreet({ name: "world" });
console.log(greeting.message);

For framework hooks generated by Stage 2 (useGreet() etc., wrapping the imperative callGreet with isPending/error state), wrap your tree with <MizanContext> at the root — same as the HTTP-transport setup. The generated provider is transport-agnostic; it reads from config.transport the kernel is using.

The @mizan/* packages are typically linked via file: in package.json. Without preserveSymlinks, both TypeScript and Vite/Rollup follow the symlinks to their real location and fail to resolve the linked packages' peer dependencies (@tauri-apps/api, @mizan/base) from there.

// tsconfig.json
{
    "compilerOptions": {
        "moduleResolution": "bundler",
        "preserveSymlinks": true,
        // …
    }
}
// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
    resolve: {
        preserveSymlinks: true,
    },
    // …
});

Wire protocol

Same envelope as the HTTP adapter, wrapped in a Tauri invoke payload:

// call
invoke('plugin:mizan|mizan_invoke', {
    envelope: { op: 'call', fn: 'greet', args: { name: 'world' } }
})
// → { result: { message: "hello, world" }, invalidate: [], merge?: [...] }

// fetch (context bundling)
invoke('plugin:mizan|mizan_invoke', {
    envelope: { op: 'fetch', context: 'user', params: { user_id: 42 } }
})
// → { user_profile: {...}, user_orders: [...] }   (flat bundle)

Errors flow through Tauri's Promise.reject path; @mizan/tauri-transport re-wraps them into the same MizanError shape the HTTP transport produces, so consumer code is identical regardless of transport.

Reference application

claude-manage is the production reference — Tauri + React + Pydantic schema + Mizan RPC. See ~/dev/claude-manage/mizan.toml and ~/dev/claude-manage/src-tauri/src/commands.rs for a full migrated app.

Architecture

mizan-tauri shares cores/mizan-rust with mizan-rust-axum. Both adapters dispatch through the same compile-time FUNCTIONS registry, same compute_invalidation / compute_merges logic, same KDL IR emitted by build_ir(). The only difference is the wire surface — axum takes POST /call/ and GET /ctx/:name/, mizan-tauri takes a single mizan_invoke command with an op-tagged envelope.