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}