//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC. //! //! Ships as a Tauri plugin. The consumer installs it with one line: //! //! ```ignore //! tauri::Builder::default() //! .plugin(mizan_tauri::init()) //! .run(tauri::generate_context!()) //! .expect("error while running tauri application"); //! ``` //! //! The plugin exposes a single command `mizan_invoke` (full Tauri name //! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport` //! sends call/fetch envelopes to it; the dispatch routes through //! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same //! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum) //! consumes. There is no per-function tauri::command; the registry IS //! the dispatch table. //! //! Wire envelope: //! //! ```json //! { "op": "call", "fn": "list_sessions", "args": {} } //! { "op": "fetch", "context": "session", "params": {} } //! ``` //! //! Response shapes mirror POST /call/ and GET /ctx/.../ from //! mizan-rust-axum: //! //! * `call` → `{ result, invalidate, merge? }` //! * `fetch` → `{ : , ... }` (a flat bundle) //! //! Error responses come back as the `Err` variant of the Tauri command's //! `Result`, which Tauri serializes into the JS-side `Promise.reject`. //! The TS-side transport re-wraps it into a `MizanError` so consumers //! see one error surface regardless of transport. use mizan_core::{ compute_invalidation, compute_merges, lookup_context, lookup_function, FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use tauri::{ plugin::{Builder, TauriPlugin}, Runtime, }; /// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())` /// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch /// command is reachable from JS as `plugin:mizan|mizan_invoke`. pub fn init() -> TauriPlugin { Builder::::new("mizan") .invoke_handler(tauri::generate_handler![mizan_invoke]) .build() } // === Wire envelope === /// One Mizan request. The JS-side transport sends `{ envelope: ... }`; /// Tauri's serde deserializer pulls this struct out of the `envelope` /// field of the invoke payload. #[derive(Debug, Deserialize)] #[serde(tag = "op")] pub enum Envelope { #[serde(rename = "call")] Call { /// Wire-level function name — registered name on the Rust side. #[serde(rename = "fn")] function_name: String, #[serde(default)] args: Map, }, #[serde(rename = "fetch")] Fetch { context: String, #[serde(default)] params: Map, }, } /// Error payload returned to the frontend. Mirrors the HTTP adapter's /// `{"code", "message", "details?"}` shape; the TS-side transport reads /// this and constructs a `MizanError`. #[derive(Debug, Serialize)] pub struct ErrorPayload { pub code: &'static str, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub details: Option, } impl From for ErrorPayload { fn from(e: MizanError) -> Self { let details = if let MizanError::ValidationFailed { details, .. } = &e { Some(details.clone()) } else { None }; Self { code: e.code(), message: e.message().to_string(), details, } } } // === Dispatch === /// The single Mizan dispatch command. Registered on the plugin's invoke /// handler — the consumer never wires it directly. /// /// `app: AppHandle` is auto-injected by Tauri; the function body borrows /// it into a `RequestHandle` so `#[mizan::client]` functions can /// `req.downcast::()` for app-managed state or event /// emission. Stateless functions ignore the handle. #[tauri::command] async fn mizan_invoke( app: tauri::AppHandle, envelope: Envelope, ) -> Result { match envelope { Envelope::Call { function_name, args, } => handle_call(&app, &function_name, args).await, Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await, } } async fn handle_call( app: &tauri::AppHandle, fn_name: &str, args: Map, ) -> Result { let fn_spec = lookup_function(fn_name).ok_or_else(|| { ErrorPayload::from(MizanError::NotFound(format!( "function {fn_name:?} not registered" ))) })?; let req = RequestHandle::new(app); let result = fn_spec .dispatch(req, Value::Object(args.clone())) .await .map_err(ErrorPayload::from)?; let invalidate: Vec = compute_invalidation(fn_spec, &args) .iter() .map(InvalidationTarget::to_json) .collect(); let merges = compute_merges(fn_spec, &args, &result); let merge_payload: Option> = if merges.is_empty() { None } else { Some(merges.iter().map(MergeEntry::to_json).collect()) }; let mut payload = json!({ "result": result, "invalidate": invalidate, }); if let Some(merge) = merge_payload { payload .as_object_mut() .expect("payload is a JSON object") .insert("merge".into(), Value::Array(merge)); } Ok(payload) } async fn handle_fetch( app: &tauri::AppHandle, context_name: &str, params: Map, ) -> Result { if lookup_context(context_name).is_none() { return Err(ErrorPayload::from(MizanError::NotFound(format!( "context {context_name:?} not registered" )))); } let members: Vec<&dyn FunctionSpec> = FUNCTIONS .iter() .copied() .filter(|f| f.context() == Some(context_name)) .collect(); if members.is_empty() { return Err(ErrorPayload::from(MizanError::NotFound(format!( "context {context_name:?} has no registered members" )))); } let mut bundled = Map::new(); for fn_spec in &members { let args = filter_args(*fn_spec, ¶ms); let req = RequestHandle::new(app); let result = fn_spec .dispatch(req, Value::Object(args)) .await .map_err(ErrorPayload::from)?; bundled.insert(fn_spec.name().to_string(), result); } Ok(Value::Object(bundled)) } /// Filter the envelope's params down to keys this function declares as /// input. The HTTP/axum adapter coerces string-typed query params to /// JSON primitives in the equivalent step; the Tauri arg channel already /// carries typed JSON, so the filter is sufficient on its own. fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map) -> Map { let mut out = Map::new(); for ip in fn_spec.input_params() { if let Some(v) = params.get(ip.name) { out.insert(ip.name.into(), v.clone()); } } out }