Latest states
This commit is contained in:
290
backends/mizan-tauri/README.md
Normal file
290
backends/mizan-tauri/README.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# 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"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# 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};
|
||||||
|
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 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() });
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
### tsconfig / vite preserve symlinks
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"preserveSymlinks": true,
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 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.
|
||||||
25
frontends/mizan-tauri-transport/README.md
Normal file
25
frontends/mizan-tauri-transport/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# @mizan/tauri-transport
|
||||||
|
|
||||||
|
Mizan transport adapter routing calls through Tauri's IPC instead of
|
||||||
|
HTTP fetch. Paired with the `mizan-tauri` Rust plugin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @mizan/base @mizan/tauri-transport @tauri-apps/api
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { configure } from "@mizan/base";
|
||||||
|
import { tauriTransport } from "@mizan/tauri-transport";
|
||||||
|
|
||||||
|
configure({ transport: tauriTransport() });
|
||||||
|
```
|
||||||
|
|
||||||
|
That's the entire surface — no other API. Once configured at the app
|
||||||
|
entry (top of `main.tsx`), every `mizanCall` / `mizanFetch` in the
|
||||||
|
generated client routes through `invoke('plugin:mizan|mizan_invoke',
|
||||||
|
{ envelope })` instead of `fetch()`. Codegen output (Stage 1 + Stage 2
|
||||||
|
React hooks) is unchanged from the HTTP case; only the wire channel
|
||||||
|
differs.
|
||||||
|
|
||||||
|
See `backends/mizan-tauri/README.md` for the Rust-side plugin setup,
|
||||||
|
envelope shape, and full end-to-end walkthrough.
|
||||||
@@ -27,7 +27,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from pydantic import BaseModel # type: ignore[import-untyped]
|
from pydantic import BaseModel # type: ignore[import-untyped]
|
||||||
|
|
||||||
from decoru import emit_rust_struct, walk_pydantic_model # type: ignore[import-untyped]
|
from decoru import ( # type: ignore[import-untyped]
|
||||||
|
emit_rust_struct,
|
||||||
|
to_rust_variant_ident,
|
||||||
|
walk_pydantic_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _declared_in(module, obj) -> bool:
|
def _declared_in(module, obj) -> bool:
|
||||||
@@ -56,10 +60,6 @@ def discover_enums(module) -> list[type[Enum]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _pascal_case(s: str) -> str:
|
|
||||||
return "".join(part.capitalize() for part in s.split("_") if part)
|
|
||||||
|
|
||||||
|
|
||||||
# Last-variant-is-default matches the catch-all idiom (e.g. `Metadata`
|
# Last-variant-is-default matches the catch-all idiom (e.g. `Metadata`
|
||||||
# in `claude_manage.schema.EntryType`). Decoru's `emit_rust_struct`
|
# in `claude_manage.schema.EntryType`). Decoru's `emit_rust_struct`
|
||||||
# emits `impl Default` unconditionally on every BaseModel, so any
|
# emits `impl Default` unconditionally on every BaseModel, so any
|
||||||
@@ -76,7 +76,10 @@ _ENUM_TEMPLATE = textwrap.dedent("""\
|
|||||||
|
|
||||||
|
|
||||||
def _render_variant(member: Enum, *, is_default: bool) -> str:
|
def _render_variant(member: Enum, *, is_default: bool) -> str:
|
||||||
pascal = _pascal_case(member.name)
|
# Pascal-casing the Python member name is the same conversion decoru
|
||||||
|
# applies when capturing enum field defaults. Sharing the function is
|
||||||
|
# load-bearing — divergent conversions emit non-compiling schema.rs.
|
||||||
|
pascal = to_rust_variant_ident(member.name)
|
||||||
default_attr = " #[default]\n" if is_default else ""
|
default_attr = " #[default]\n" if is_default else ""
|
||||||
return f"{default_attr} {pascal},"
|
return f"{default_attr} {pascal},"
|
||||||
|
|
||||||
|
|||||||
1515
tests/rust/fixture_client/Cargo.lock
generated
Normal file
1515
tests/rust/fixture_client/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user