Restore approved state (tree of 4effcc7 "Added LICENSE")
Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.
Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC, riding the
|
||||
//! shared `mizan-core` dispatch/auth/cache/invalidation/shapes logic.
|
||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||
//!
|
||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||
//!
|
||||
@@ -10,137 +9,79 @@
|
||||
//! .expect("error while running tauri application");
|
||||
//! ```
|
||||
//!
|
||||
//! The plugin exposes commands reachable from the JS-side
|
||||
//! `@mizan/tauri-transport`:
|
||||
//! 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.
|
||||
//!
|
||||
//! * `mizan_invoke` — call / fetch / shape / form dispatch (the request/
|
||||
//! response surface, mirroring the HTTP adapter's POST /call/ + GET /ctx/).
|
||||
//! * `mizan_subscribe` — opens an IPC subscription `Channel` for a
|
||||
//! `#[mizan(websocket)]` function; this is the IPC transport's analogue of
|
||||
//! the HTTP WebSocket — there are no sockets in a desktop shell, so a
|
||||
//! Tauri `Channel<T>` carries the push stream instead.
|
||||
//!
|
||||
//! Wire envelope (the `mizan_invoke` payload's `envelope` field):
|
||||
//! Wire envelope:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "op": "call", "fn": "list_sessions", "args": {}, "token": "..."? }
|
||||
//! { "op": "fetch", "context": "session", "params": {}, "token": "..."? }
|
||||
//! { "op": "shape", "fn": "user_profile" }
|
||||
//! { "op": "form", "form": "contact", "role": "submit", "args": {} }
|
||||
//! { "op": "call", "fn": "list_sessions", "args": {} }
|
||||
//! { "op": "fetch", "context": "session", "params": {} }
|
||||
//! ```
|
||||
//!
|
||||
//! Response shapes mirror the HTTP adapter:
|
||||
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
||||
//! mizan-rust-axum:
|
||||
//!
|
||||
//! * `call` → `{ result, invalidate, merge? }`
|
||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||
//! * `shape` → `{ type, fields }`
|
||||
//! * `form` → the form function's result
|
||||
//! * `call` → `{ result, invalidate, merge? }`
|
||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||
//!
|
||||
//! Auth: the envelope's optional `token` carries an MWT (`X-Mizan-Token`
|
||||
//! equivalent) or a `Bearer <jwt>`; it is resolved through the shared
|
||||
//! `authenticate` and enforced against each function's `auth=` requirement.
|
||||
//! There is no header channel over IPC, so the token rides the envelope.
|
||||
//!
|
||||
//! Errors come back as the `Err` variant of the command's `Result`, which
|
||||
//! Tauri serializes into the JS-side rejection; the TS transport re-wraps it
|
||||
//! into a `MizanError`.
|
||||
|
||||
mod ssr;
|
||||
|
||||
pub use ssr::{ssr_render, MizanSsr};
|
||||
//! 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::{
|
||||
authenticate, compute_invalidation, compute_merges, enforce_auth, lookup_context,
|
||||
lookup_function, now_unix, shapes, AuthConfig, AuthOutcome, AuthRequirement, CacheOrchestrator,
|
||||
FunctionSpec, Identity, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
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::ipc::Channel;
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Manager, Runtime,
|
||||
Runtime,
|
||||
};
|
||||
|
||||
/// The Mizan config Tauri manages: auth (token → identity) + the origin cache.
|
||||
/// The consumer registers it with `app.manage(MizanTauriConfig { .. })`; the
|
||||
/// dispatch commands read it from managed state.
|
||||
pub struct MizanTauriConfig {
|
||||
pub auth: AuthConfig,
|
||||
pub cache: CacheOrchestrator,
|
||||
}
|
||||
|
||||
impl Default for MizanTauriConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auth: AuthConfig::new(),
|
||||
cache: CacheOrchestrator::disabled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`.
|
||||
/// Registers a default (auth-off, cache-disabled) config if the consumer
|
||||
/// hasn't managed one; commands are reachable as `plugin:mizan|mizan_invoke`
|
||||
/// and `plugin:mizan|mizan_subscribe`.
|
||||
/// 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<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::<R>::new("mizan")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
mizan_invoke,
|
||||
mizan_subscribe,
|
||||
ssr::ssr_render
|
||||
])
|
||||
.setup(|app, _api| {
|
||||
if app.try_state::<MizanTauriConfig>().is_none() {
|
||||
app.manage(MizanTauriConfig::default());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![mizan_invoke])
|
||||
.build()
|
||||
}
|
||||
|
||||
// === Wire envelope ===
|
||||
|
||||
/// One Mizan request. Tauri's serde deserializer pulls this out of the
|
||||
/// `envelope` field of the invoke payload.
|
||||
/// 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<String, Value>,
|
||||
/// Optional auth token (MWT, or `Bearer <jwt>`) — the IPC analogue of
|
||||
/// the HTTP `X-Mizan-Token` / `Authorization` headers.
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
},
|
||||
#[serde(rename = "fetch")]
|
||||
Fetch {
|
||||
context: String,
|
||||
#[serde(default)]
|
||||
params: Map<String, Value>,
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
},
|
||||
#[serde(rename = "shape")]
|
||||
Shape {
|
||||
#[serde(rename = "fn")]
|
||||
function_name: String,
|
||||
},
|
||||
#[serde(rename = "form")]
|
||||
Form {
|
||||
form: String,
|
||||
role: String,
|
||||
#[serde(default)]
|
||||
args: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||
/// `{"code", "message", "details?"}` shape.
|
||||
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
||||
/// this and constructs a `MizanError`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorPayload {
|
||||
pub code: &'static str,
|
||||
@@ -164,336 +105,110 @@ impl From<MizanError> for ErrorPayload {
|
||||
}
|
||||
}
|
||||
|
||||
// === Auth ===
|
||||
// === Dispatch ===
|
||||
|
||||
/// Resolve identity from an envelope `token`. An MWT is tried first (raw
|
||||
/// token), then a `Bearer <jwt>`. A present-but-invalid token rejects (the
|
||||
/// `INVALID`-sentinel contract); absent → anonymous.
|
||||
fn identity_from_token(
|
||||
token: Option<&str>,
|
||||
config: &MizanTauriConfig,
|
||||
) -> Result<Option<Identity>, MizanError> {
|
||||
let (mwt, bearer) = match token {
|
||||
Some(t) if t.starts_with("Bearer ") => (None, Some(t)),
|
||||
Some(t) => (Some(t), None),
|
||||
None => (None, None),
|
||||
};
|
||||
match authenticate(mwt, bearer, &config.auth, now_unix()) {
|
||||
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
||||
AuthOutcome::Anonymous => Ok(None),
|
||||
AuthOutcome::Invalid => Err(MizanError::Unauthorized("Invalid or expired token".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), MizanError> {
|
||||
enforce_auth(identity, &AuthRequirement::from_str_opt(fn_spec.auth()))
|
||||
}
|
||||
|
||||
// === Dispatch commands ===
|
||||
|
||||
/// The single Mizan request/response command. Tauri auto-injects `app`; the
|
||||
/// body borrows it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||
/// `req.downcast::<tauri::AppHandle>()` for managed state or event emission.
|
||||
/// 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::<tauri::AppHandle>()` for app-managed state or event
|
||||
/// emission. Stateless functions ignore the handle.
|
||||
#[tauri::command]
|
||||
async fn mizan_invoke<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
envelope: Envelope,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
dispatch(&app, envelope).await.map_err(ErrorPayload::from)
|
||||
}
|
||||
|
||||
/// Dispatch one Mizan [`Envelope`] against an `AppHandle`, returning the JSON
|
||||
/// response (or a `MizanError`). This is the programmatic entry point the
|
||||
/// `mizan_invoke` IPC command wraps — exposed so embedders (and behavior
|
||||
/// tests) can drive the Mizan protocol without the IPC serialization layer.
|
||||
pub async fn dispatch<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
envelope: Envelope,
|
||||
) -> Result<Value, MizanError> {
|
||||
// Read the managed config (lifetime-bound to `app`, which outlives this
|
||||
// dispatch); fall back to a default if none was registered. The `State`
|
||||
// guard is held across the awaits below.
|
||||
let managed = app.try_state::<MizanTauriConfig>();
|
||||
let default;
|
||||
let cfg: &MizanTauriConfig = match managed.as_ref() {
|
||||
Some(state) => state.inner(),
|
||||
None => {
|
||||
default = MizanTauriConfig::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
match envelope {
|
||||
Envelope::Call {
|
||||
function_name,
|
||||
args,
|
||||
token,
|
||||
} => handle_call(app, cfg, &function_name, args, token.as_deref()).await,
|
||||
Envelope::Fetch {
|
||||
context,
|
||||
params,
|
||||
token,
|
||||
} => handle_fetch(app, cfg, &context, params, token.as_deref()).await,
|
||||
Envelope::Shape { function_name } => handle_shape(&function_name),
|
||||
Envelope::Form { form, role, args } => handle_form(app, &form, &role, args).await,
|
||||
} => handle_call(&app, &function_name, args).await,
|
||||
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_call<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
cfg: &MizanTauriConfig,
|
||||
fn_name: &str,
|
||||
mut args: Map<String, Value>,
|
||||
token: Option<&str>,
|
||||
) -> Result<Value, MizanError> {
|
||||
let identity = identity_from_token(token, cfg)?;
|
||||
|
||||
let fn_spec = lookup_function(fn_name)
|
||||
.ok_or_else(|| MizanError::NotFound(format!("function {fn_name:?} not registered")))?;
|
||||
if fn_spec.private() {
|
||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
||||
}
|
||||
guard(fn_spec, identity.as_ref())?;
|
||||
|
||||
// Bind any file parts the envelope carries into the call args (see
|
||||
// `bind_uploads`).
|
||||
bind_uploads(fn_spec, &mut args)?;
|
||||
args: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
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?;
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args.clone()))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
|
||||
let targets = compute_invalidation(fn_spec, &args);
|
||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
|
||||
// Purge the origin cache for everything this mutation invalidated.
|
||||
if !targets.is_empty() {
|
||||
let uid = identity.as_ref().map(|i| i.user_id.clone());
|
||||
cfg.cache.purge(&targets, uid.as_deref());
|
||||
}
|
||||
|
||||
let mut payload = json!({ "result": result, "invalidate": invalidate });
|
||||
if !merges.is_empty() {
|
||||
payload.as_object_mut().unwrap().insert(
|
||||
"merge".into(),
|
||||
Value::Array(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<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
cfg: &MizanTauriConfig,
|
||||
context_name: &str,
|
||||
params: Map<String, Value>,
|
||||
token: Option<&str>,
|
||||
) -> Result<Value, MizanError> {
|
||||
let identity = identity_from_token(token, cfg)?;
|
||||
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
if lookup_context(context_name).is_none() {
|
||||
return Err(MizanError::NotFound(format!(
|
||||
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(MizanError::NotFound(format!(
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} has no registered members"
|
||||
)));
|
||||
}
|
||||
|
||||
// Origin cache: a desktop shell still benefits from memoizing a context
|
||||
// bundle by (context, params, user). Key the params as JSON values.
|
||||
let cache_params: std::collections::BTreeMap<String, Value> = params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
let uid = identity.as_ref().map(|i| i.user_id.clone());
|
||||
|
||||
if let Some(cached) = cfg
|
||||
.cache
|
||||
.get(context_name, &cache_params, uid.as_deref(), 0)
|
||||
{
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(&cached) {
|
||||
return Ok(v);
|
||||
}
|
||||
))));
|
||||
}
|
||||
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in &members {
|
||||
guard(*fn_spec, identity.as_ref())?;
|
||||
let args = filter_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
|
||||
let body = Value::Object(bundled);
|
||||
if cfg.cache.enabled() {
|
||||
let bytes = serde_json::to_vec(&body).unwrap();
|
||||
cfg.cache
|
||||
.put(context_name, &cache_params, bytes, uid.as_deref(), 0);
|
||||
}
|
||||
Ok(body)
|
||||
Ok(Value::Object(bundled))
|
||||
}
|
||||
|
||||
/// `shape` op — the typed query projection for a function's output, derived by
|
||||
/// the shared `mizan_core::shapes` (the IPC adapter's Shapes binding).
|
||||
fn handle_shape(fn_name: &str) -> Result<Value, MizanError> {
|
||||
let proj = shapes::project_function_output(fn_name)
|
||||
.ok_or_else(|| MizanError::NotFound(format!("no shape projection for {fn_name:?}")))?;
|
||||
Ok(projection_to_json(&proj))
|
||||
}
|
||||
|
||||
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
||||
let mut fields = Vec::new();
|
||||
for f in &proj.fields {
|
||||
match f {
|
||||
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
||||
shapes::ShapeField::Nested(n, sub) => {
|
||||
fields.push(json!({ n.clone(): projection_to_json(sub) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({ "type": proj.type_name, "fields": fields })
|
||||
}
|
||||
|
||||
/// `form` op — dispatch a form's schema/validate/submit function (the IPC
|
||||
/// Forms binding). `form_validate` / `form_submit` map to the registered
|
||||
/// function whose `(form_name, form_role)` matches.
|
||||
async fn handle_form<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
role: &str,
|
||||
args: Value,
|
||||
) -> Result<Value, MizanError> {
|
||||
match role {
|
||||
"schema" => form_schema(app, form_name).await,
|
||||
"validate" => form_validate(app, form_name, args).await,
|
||||
"submit" => form_submit(app, form_name, args).await,
|
||||
other => Err(MizanError::BadRequest(format!(
|
||||
"unknown form role {other:?} (expected schema|validate|submit)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
|
||||
FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
|
||||
}
|
||||
|
||||
async fn dispatch_form_role<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
role: &str,
|
||||
args: Value,
|
||||
) -> Result<Value, MizanError> {
|
||||
let fn_spec = lookup_form_fn(form_name, role)
|
||||
.ok_or_else(|| MizanError::NotFound(format!("no form {form_name:?} with role {role:?}")))?;
|
||||
let args_value = match args {
|
||||
Value::Object(_) | Value::Null => args,
|
||||
other => json!({ "data": other }),
|
||||
};
|
||||
let req = RequestHandle::new(app);
|
||||
fn_spec.dispatch(req, args_value).await
|
||||
}
|
||||
|
||||
async fn form_schema<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
) -> Result<Value, MizanError> {
|
||||
dispatch_form_role(app, form_name, "schema", Value::Null).await
|
||||
}
|
||||
|
||||
async fn form_validate<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
args: Value,
|
||||
) -> Result<Value, MizanError> {
|
||||
dispatch_form_role(app, form_name, "validate", args).await
|
||||
}
|
||||
|
||||
async fn form_submit<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
args: Value,
|
||||
) -> Result<Value, MizanError> {
|
||||
dispatch_form_role(app, form_name, "submit", args).await
|
||||
}
|
||||
|
||||
// === WebSocket-equivalent: IPC subscription channel ===
|
||||
|
||||
/// One frame pushed down a subscription `Channel`. Mirrors the WS reply shape.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct SubscriptionFrame {
|
||||
pub result: Value,
|
||||
pub invalidate: Vec<Value>,
|
||||
}
|
||||
|
||||
/// `mizan_subscribe` — open an IPC subscription for a `#[mizan(websocket)]`
|
||||
/// function. A desktop shell has no WebSocket; a Tauri `Channel<T>` carries
|
||||
/// the push stream instead — the IPC transport's co-equal of the HTTP
|
||||
/// WebSocket. The initial dispatch result is emitted immediately on the
|
||||
/// channel; subsequent server-side pushes use the same `on_event` channel.
|
||||
#[tauri::command]
|
||||
async fn mizan_subscribe<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
function_name: String,
|
||||
args: Map<String, Value>,
|
||||
on_event: Channel<SubscriptionFrame>,
|
||||
) -> Result<(), ErrorPayload> {
|
||||
subscribe(&app, &function_name, args, on_event)
|
||||
.await
|
||||
.map_err(ErrorPayload::from)
|
||||
}
|
||||
|
||||
/// Open a subscription for a `#[mizan(websocket)]` function, pushing frames on
|
||||
/// `on_event`. The programmatic entry point the `mizan_subscribe` IPC command
|
||||
/// wraps — exposed for embedders and behavior tests.
|
||||
pub async fn subscribe<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
function_name: &str,
|
||||
args: Map<String, Value>,
|
||||
on_event: Channel<SubscriptionFrame>,
|
||||
) -> Result<(), MizanError> {
|
||||
let fn_spec = lookup_function(function_name)
|
||||
.ok_or_else(|| MizanError::NotFound(format!("function {function_name:?} not registered")))?;
|
||||
if fn_spec.private() {
|
||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
||||
}
|
||||
// Only `#[mizan(websocket)]` functions are exposed over the subscription
|
||||
// channel — the same transport boundary the HTTP WebSocket enforces.
|
||||
if !matches!(
|
||||
fn_spec.transport(),
|
||||
mizan_core::Transport::Websocket | mizan_core::Transport::Both
|
||||
) {
|
||||
return Err(MizanError::BadRequest(format!(
|
||||
"function {function_name:?} is not exposed over the subscription transport"
|
||||
)));
|
||||
}
|
||||
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
||||
let invalidate = compute_invalidation(fn_spec, &args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
|
||||
on_event
|
||||
.send(SubscriptionFrame { result, invalidate })
|
||||
.map_err(|e| MizanError::InternalError(format!("subscription channel send failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/// Filter the envelope's params down to keys this function declares as input.
|
||||
/// 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<String, Value>) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
@@ -503,45 +218,3 @@ fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<S
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bind file parts carried in the IPC envelope into the call args.
|
||||
///
|
||||
/// Over IPC there is no `multipart/form-data`; a file rides the envelope as a
|
||||
/// JSON object `{filename, content_type, data_b64}` (the JS transport
|
||||
/// base64-packs the bytes). That object is exactly what `mizan_core::Upload`
|
||||
/// deserializes, so for a single file the arg is already in place. This binder
|
||||
/// performs the one transform IPC needs: a top-level `_files` map
|
||||
/// (`{ field: <file-obj> | [<file-obj>, ...] }`) is merged into the args under
|
||||
/// each field name, mirroring how the HTTP adapter binds multipart parts. It
|
||||
/// also validates that anything presenting as a file carries `data_b64`,
|
||||
/// surfacing a clear error before the typed `Upload` deserialize runs.
|
||||
fn bind_uploads(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
args: &mut Map<String, Value>,
|
||||
) -> Result<(), MizanError> {
|
||||
if let Some(Value::Object(files)) = args.remove("_files") {
|
||||
for (field, parts) in files {
|
||||
args.insert(field, parts);
|
||||
}
|
||||
}
|
||||
|
||||
// The set of param names this function declares — only validate args that
|
||||
// could land in a typed field.
|
||||
let declared: std::collections::HashSet<&str> =
|
||||
fn_spec.input_params().iter().map(|p| p.name).collect();
|
||||
for (name, value) in args.iter() {
|
||||
if !declared.contains(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Value::Object(obj) = value {
|
||||
let looks_like_file =
|
||||
obj.contains_key("filename") || obj.contains_key("content_type");
|
||||
if looks_like_file && !obj.contains_key("data_b64") {
|
||||
return Err(MizanError::BadRequest(format!(
|
||||
"upload field {name:?} is missing `data_b64` (the base64 file bytes)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user