Skip to main content

tauri_plugin_conduit/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3//! # tauri-plugin-conduit
4//!
5//! Tauri v2 plugin for conduit — binary IPC over the `conduit://` custom
6//! protocol.
7//!
8//! Registers a `conduit://` custom protocol for zero-overhead in-process
9//! binary dispatch via a synchronous handler table. No network surface.
10//!
11//! ## Usage
12//!
13//! ```rust,ignore
14//! tauri::Builder::default()
15//!     .plugin(
16//!         tauri_plugin_conduit::init()
17//!             .command("ping", |_| b"pong".to_vec())
18//!             .command("get_ticks", handle_ticks)
19//!             .channel("telemetry")           // ring buffer for streaming
20//!             .build()
21//!     )
22//!     .run(tauri::generate_context!())
23//!     .unwrap();
24//! ```
25
26use std::collections::HashMap;
27use std::sync::Arc;
28
29use conduit_core::{RingBuffer, Router};
30use subtle::ConstantTimeEq;
31use tauri::plugin::{Builder as TauriPluginBuilder, TauriPlugin};
32use tauri::{AppHandle, Emitter, Manager, Runtime};
33
34// ---------------------------------------------------------------------------
35// Helper: safe HTTP response builder
36// ---------------------------------------------------------------------------
37
38/// Build an HTTP response, falling back to a minimal 500 if construction fails.
39fn make_response(status: u16, content_type: &str, body: Vec<u8>) -> http::Response<Vec<u8>> {
40    http::Response::builder()
41        .status(status)
42        .header("Content-Type", content_type)
43        .body(body)
44        .unwrap_or_else(|_| {
45            http::Response::builder()
46                .status(500)
47                .body(b"internal error".to_vec())
48                .expect("fallback response must not fail")
49        })
50}
51
52// ---------------------------------------------------------------------------
53// BootstrapInfo — returned to JS via `conduit_bootstrap` command
54// ---------------------------------------------------------------------------
55
56/// Connection info returned to the frontend during bootstrap.
57#[derive(Clone, serde::Serialize, serde::Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct BootstrapInfo {
60    /// Base URL for the custom protocol (e.g., `"conduit://localhost"`).
61    pub protocol_base: String,
62    /// Per-launch invoke key for custom protocol authentication (hex-encoded).
63    pub invoke_key: String,
64    /// Available ring buffer channel names.
65    pub channels: Vec<String>,
66}
67
68impl std::fmt::Debug for BootstrapInfo {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("BootstrapInfo")
71            .field("protocol_base", &self.protocol_base)
72            .field("invoke_key", &"[REDACTED]")
73            .field("channels", &self.channels)
74            .finish()
75    }
76}
77
78// ---------------------------------------------------------------------------
79// PluginState — managed Tauri state
80// ---------------------------------------------------------------------------
81
82/// Shared state for the conduit Tauri plugin.
83///
84/// Holds the dispatch table, named ring buffer channels, the per-launch
85/// invoke key, and the app handle for emitting push notifications.
86pub struct PluginState<R: Runtime> {
87    dispatch: Arc<Router>,
88    /// Named ring buffer channels for server→client streaming.
89    channels: HashMap<String, Arc<RingBuffer>>,
90    /// Tauri app handle for emitting events to the frontend.
91    app_handle: AppHandle<R>,
92    /// Per-launch invoke key (hex-encoded, 64 hex chars = 32 bytes).
93    invoke_key: String,
94    /// Raw invoke key bytes for constant-time comparison.
95    invoke_key_bytes: [u8; 32],
96}
97
98impl<R: Runtime> std::fmt::Debug for PluginState<R> {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("PluginState")
101            .field("channels", &self.channels.keys().collect::<Vec<_>>())
102            .field("invoke_key", &"[REDACTED]")
103            .finish()
104    }
105}
106
107impl<R: Runtime> PluginState<R> {
108    /// Get a ring buffer channel by name (for pushing data from Rust handlers).
109    pub fn channel(&self, name: &str) -> Option<&Arc<RingBuffer>> {
110        self.channels.get(name)
111    }
112
113    /// Push binary data to a named ring buffer channel and notify JS listeners.
114    ///
115    /// After writing to the ring buffer, emits a `conduit:data-available` event
116    /// with the channel name as payload. JS subscribers receive this event and
117    /// auto-drain the binary data via the custom protocol endpoint.
118    pub fn push(&self, channel: &str, data: &[u8]) -> Result<(), String> {
119        let rb = self
120            .channels
121            .get(channel)
122            .ok_or_else(|| format!("unknown channel: {channel}"))?;
123        let _ = rb.push(data);
124        let _ = self.app_handle.emit("conduit:data-available", channel);
125        Ok(())
126    }
127
128    /// Return the list of registered channel names.
129    pub fn channel_names(&self) -> Vec<String> {
130        self.channels.keys().cloned().collect()
131    }
132
133    /// Validate an invoke key candidate using constant-time comparison.
134    fn validate_invoke_key(&self, candidate: &str) -> bool {
135        let candidate_bytes = match hex_decode(candidate) {
136            Some(b) => b,
137            None => return false,
138        };
139        if candidate_bytes.len() != 32 {
140            return false;
141        }
142        let ok: bool = self
143            .invoke_key_bytes
144            .ct_eq(&candidate_bytes)
145            .into();
146        ok
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Tauri commands
152// ---------------------------------------------------------------------------
153
154/// Return bootstrap info so the JS client knows how to reach the conduit
155/// custom protocol.
156#[tauri::command]
157fn conduit_bootstrap(
158    state: tauri::State<'_, PluginState<tauri::Wry>>,
159) -> Result<BootstrapInfo, String> {
160    Ok(BootstrapInfo {
161        protocol_base: "conduit://localhost".to_string(),
162        invoke_key: state.invoke_key.clone(),
163        channels: state.channel_names(),
164    })
165}
166
167/// Subscribe to a channel (or list of channels). Returns the list of channel
168/// names that were successfully subscribed. The actual data delivery happens
169/// via `conduit:data-available` events + protocol drain.
170#[tauri::command]
171fn conduit_subscribe(
172    state: tauri::State<'_, PluginState<tauri::Wry>>,
173    channels: Vec<String>,
174) -> Result<Vec<String>, String> {
175    // Validate that all requested channels exist.
176    let mut subscribed = Vec::new();
177    for ch in &channels {
178        if state.channels.contains_key(ch) {
179            subscribed.push(ch.clone());
180        }
181    }
182    Ok(subscribed)
183}
184
185// ---------------------------------------------------------------------------
186// Plugin builder
187// ---------------------------------------------------------------------------
188
189/// A deferred command registration closure.
190type CommandRegistration = Box<dyn FnOnce(&Router) + Send>;
191
192/// Builder for the conduit Tauri v2 plugin.
193///
194/// Collects command registrations and configuration, then produces a
195/// [`TauriPlugin`] via [`build`](Self::build).
196pub struct PluginBuilder {
197    /// Deferred command registrations: (name, handler factory).
198    commands: Vec<CommandRegistration>,
199    /// Named ring buffer channels: (name, capacity in bytes).
200    channel_defs: Vec<(String, usize)>,
201}
202
203impl std::fmt::Debug for PluginBuilder {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.debug_struct("PluginBuilder")
206            .field("commands", &self.commands.len())
207            .field("channel_defs", &self.channel_defs)
208            .finish()
209    }
210}
211
212/// Default ring buffer capacity (64 KB).
213const DEFAULT_CHANNEL_CAPACITY: usize = 64 * 1024;
214
215impl PluginBuilder {
216    /// Create a new, empty plugin builder.
217    pub fn new() -> Self {
218        Self {
219            commands: Vec::new(),
220            channel_defs: Vec::new(),
221        }
222    }
223
224    /// Register a synchronous command handler.
225    ///
226    /// The handler receives the raw request payload and must return the raw
227    /// response payload. Command names correspond to the path segment in the
228    /// `conduit://localhost/invoke/<cmd_name>` URL.
229    pub fn command<F>(mut self, name: impl Into<String>, handler: F) -> Self
230    where
231        F: Fn(Vec<u8>) -> Vec<u8> + Send + Sync + 'static,
232    {
233        let name = name.into();
234        self.commands.push(Box::new(move |table: &Router| {
235            table.register(name, handler);
236        }));
237        self
238    }
239
240    /// Register a named ring buffer channel with the default capacity (64 KB).
241    ///
242    /// The JS client can subscribe to push notifications for this channel,
243    /// or poll it directly via `conduit://localhost/drain/<name>`.
244    pub fn channel(mut self, name: impl Into<String>) -> Self {
245        self.channel_defs
246            .push((name.into(), DEFAULT_CHANNEL_CAPACITY));
247        self
248    }
249
250    /// Register a named ring buffer channel with a custom byte capacity.
251    pub fn channel_with_capacity(mut self, name: impl Into<String>, capacity: usize) -> Self {
252        self.channel_defs.push((name.into(), capacity));
253        self
254    }
255
256    /// Build the Tauri v2 plugin.
257    ///
258    /// This consumes the builder and returns a [`TauriPlugin`] that can be
259    /// passed to `tauri::Builder::plugin`.
260    pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
261        let commands = self.commands;
262        let channel_defs = self.channel_defs;
263
264        TauriPluginBuilder::<R>::new("conduit")
265            // --- Custom protocol: conduit://localhost/invoke/<cmd> ---
266            .register_uri_scheme_protocol("conduit", move |ctx, request| {
267                // Extract the managed PluginState from the app handle.
268                let state: tauri::State<'_, PluginState<R>> = ctx.app_handle().state();
269
270                let url = request.uri().to_string();
271
272                // Parse the URL to extract the command name.
273                // Expected format: conduit://localhost/invoke/<cmd_name>
274                let parsed = match url::Url::parse(&url) {
275                    Ok(u) => u,
276                    Err(_) => {
277                        return make_response(400, "text/plain", b"invalid URL".to_vec());
278                    }
279                };
280
281                let path = parsed.path(); // e.g. "/invoke/ping"
282                let segments: Vec<&str> = path
283                    .trim_start_matches('/')
284                    .splitn(2, '/')
285                    .collect();
286
287                if segments.len() != 2 {
288                    return make_response(404, "text/plain", b"not found: expected /invoke/<cmd> or /drain/<channel>".to_vec());
289                }
290
291                // Validate the invoke key from the X-Conduit-Key header (common to all routes).
292                let key = match request.headers().get("X-Conduit-Key") {
293                    Some(v) => match v.to_str() {
294                        Ok(s) => s.to_string(),
295                        Err(_) => return make_response(401, "text/plain", b"invalid invoke key header".to_vec()),
296                    },
297                    None => return make_response(401, "text/plain", b"missing invoke key".to_vec()),
298                };
299
300                if !state.validate_invoke_key(&key) {
301                    return make_response(403, "text/plain", b"invalid invoke key".to_vec());
302                }
303
304                let action = segments[0];
305                let target = segments[1];
306
307                match action {
308                    "invoke" => {
309                        let body = request.body().to_vec();
310
311                        let dispatch = Arc::clone(&state.dispatch);
312                        let result = std::panic::catch_unwind(
313                            std::panic::AssertUnwindSafe(|| {
314                                dispatch.call_or_error_bytes(target, body)
315                            })
316                        );
317
318                        match result {
319                            Ok(response_payload) => {
320                                make_response(200, "application/octet-stream", response_payload)
321                            }
322                            Err(_) => {
323                                make_response(500, "text/plain", b"handler panicked".to_vec())
324                            }
325                        }
326                    }
327                    "drain" => {
328                        // Drain all frames from the named ring buffer channel.
329                        match state.channel(target) {
330                            Some(rb) => {
331                                let blob = rb.drain_all();
332                                make_response(200, "application/octet-stream", blob)
333                            }
334                            None => make_response(404, "text/plain", format!("unknown channel: {target}").into_bytes()),
335                        }
336                    }
337                    _ => make_response(404, "text/plain", b"not found: expected /invoke/<cmd> or /drain/<channel>".to_vec()),
338                }
339            })
340            // --- Register Tauri IPC commands ---
341            .invoke_handler(tauri::generate_handler![
342                conduit_bootstrap,
343                conduit_subscribe,
344            ])
345            // --- Plugin setup: create state, register commands ---
346            .setup(move |app, _api| {
347                let dispatch = Arc::new(Router::new());
348
349                // Register all commands that were added via the builder.
350                for register_fn in commands {
351                    register_fn(&dispatch);
352                }
353
354                // Create named ring buffer channels.
355                let mut channels = HashMap::new();
356                for (name, capacity) in channel_defs {
357                    channels.insert(name, Arc::new(RingBuffer::new(capacity)));
358                }
359
360                // Generate the per-launch invoke key.
361                let invoke_key_bytes = generate_invoke_key_bytes();
362                let invoke_key = hex_encode(&invoke_key_bytes);
363
364                // Obtain the app handle for emitting events.
365                let app_handle = app.app_handle().clone();
366
367                let state = PluginState {
368                    dispatch,
369                    channels,
370                    app_handle,
371                    invoke_key,
372                    invoke_key_bytes,
373                };
374
375                app.manage(state);
376
377                Ok(())
378            })
379            .build()
380    }
381}
382
383impl Default for PluginBuilder {
384    fn default() -> Self {
385        Self::new()
386    }
387}
388
389// ---------------------------------------------------------------------------
390// Public init function
391// ---------------------------------------------------------------------------
392
393/// Create a new conduit plugin builder.
394///
395/// This is the main entry point for using the conduit Tauri plugin:
396///
397/// ```rust,ignore
398/// tauri::Builder::default()
399///     .plugin(
400///         tauri_plugin_conduit::init()
401///             .command("ping", |_| b"pong".to_vec())
402///             .channel("telemetry")
403///             .build()
404///     )
405///     .run(tauri::generate_context!())
406///     .unwrap();
407/// ```
408pub fn init() -> PluginBuilder {
409    PluginBuilder::new()
410}
411
412// ---------------------------------------------------------------------------
413// Helpers
414// ---------------------------------------------------------------------------
415
416/// Generate 32 random bytes for the per-launch invoke key.
417fn generate_invoke_key_bytes() -> [u8; 32] {
418    let mut bytes = [0u8; 32];
419    getrandom::fill(&mut bytes).expect("conduit: failed to generate invoke key");
420    bytes
421}
422
423/// Hex-encode a byte slice.
424fn hex_encode(bytes: &[u8]) -> String {
425    let mut hex = String::with_capacity(bytes.len() * 2);
426    for b in bytes {
427        hex.push_str(&format!("{b:02x}"));
428    }
429    hex
430}
431
432/// Hex-decode a string into bytes. Returns `None` on invalid input.
433fn hex_decode(hex: &str) -> Option<Vec<u8>> {
434    if hex.len() % 2 != 0 {
435        return None;
436    }
437    let mut bytes = Vec::with_capacity(hex.len() / 2);
438    for chunk in hex.as_bytes().chunks(2) {
439        let hi = hex_digit(chunk[0])?;
440        let lo = hex_digit(chunk[1])?;
441        bytes.push((hi << 4) | lo);
442    }
443    Some(bytes)
444}
445
446/// Convert a single ASCII hex character to its 4-bit numeric value.
447fn hex_digit(b: u8) -> Option<u8> {
448    match b {
449        b'0'..=b'9' => Some(b - b'0'),
450        b'a'..=b'f' => Some(b - b'a' + 10),
451        b'A'..=b'F' => Some(b - b'A' + 10),
452        _ => None,
453    }
454}