Skip to main content

victauri_plugin/
lib.rs

1//! Victauri — full-stack introspection for Tauri apps via an embedded MCP server.
2//!
3//! Add this plugin to your Tauri app for AI-agent-driven testing and debugging:
4//! DOM snapshots, IPC tracing, cross-boundary verification, and 59 more tools —
5//! all accessible over the Model Context Protocol.
6//!
7//! # Quick Start
8//!
9//! ```ignore
10//! tauri::Builder::default()
11//!     .plugin(victauri_plugin::init())
12//!     .run(tauri::generate_context!())
13//!     .unwrap();
14//! ```
15//!
16//! In debug builds this starts an MCP server on port 7373. In release builds
17//! the plugin is a no-op with zero overhead.
18//!
19//! # Configuration
20//!
21//! ```ignore
22//! tauri::Builder::default()
23//!     .plugin(
24//!         victauri_plugin::VictauriBuilder::new()
25//!             .port(8080)
26//!             .generate_auth_token()
27//!             .strict_privacy_mode()
28//!             .build(),
29//!     )
30//!     .run(tauri::generate_context!())
31//!     .unwrap();
32//! ```
33
34pub mod bridge;
35pub mod error;
36mod js_bridge;
37pub mod mcp;
38mod memory;
39pub mod privacy;
40pub mod redaction;
41pub(crate) mod screenshot;
42mod tools;
43
44pub mod auth;
45
46use std::collections::{HashMap, HashSet};
47use std::sync::Arc;
48use std::sync::atomic::AtomicU64;
49use tauri::plugin::{Builder, TauriPlugin};
50use tauri::{Manager, RunEvent, Runtime};
51use tokio::sync::{Mutex, oneshot, watch};
52use victauri_core::{CommandRegistry, EventLog, EventRecorder};
53
54pub use error::BuilderError;
55
56pub use victauri_macros::inspectable;
57
58const DEFAULT_PORT: u16 = 7373;
59const DEFAULT_EVENT_CAPACITY: usize = 10_000;
60const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
61const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
62
63/// Map of pending JavaScript eval callbacks, keyed by request ID.
64/// Each entry holds a oneshot sender that resolves when the webview returns a result.
65pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
66
67/// Runtime state shared between the MCP server and all tool handlers.
68pub struct VictauriState {
69    /// Ring-buffer event log for IPC calls, state changes, and DOM mutations.
70    pub event_log: EventLog,
71    /// Registry of all discovered Tauri commands with metadata.
72    pub registry: CommandRegistry,
73    /// TCP port the MCP server listens on.
74    pub port: u16,
75    /// Pending JavaScript eval callbacks awaiting webview responses.
76    pub pending_evals: PendingCallbacks,
77    /// Session recorder for time-travel debugging.
78    pub recorder: EventRecorder,
79    /// Privacy configuration (tool disabling, command filtering, output redaction).
80    pub privacy: privacy::PrivacyConfig,
81    /// Timeout for JavaScript eval operations.
82    pub eval_timeout: std::time::Duration,
83    /// Sends `true` to signal graceful MCP server shutdown.
84    pub shutdown_tx: watch::Sender<bool>,
85    /// Instant the plugin was initialized, for uptime tracking.
86    pub started_at: std::time::Instant,
87    /// Total number of MCP tool invocations since startup.
88    pub tool_invocations: AtomicU64,
89}
90
91/// Builder for configuring the Victauri plugin before adding it to a Tauri app.
92///
93/// Supports port selection, authentication, privacy controls, output redaction,
94/// and capacity tuning. All settings have sensible defaults and can be overridden
95/// via environment variables.
96pub struct VictauriBuilder {
97    port: Option<u16>,
98    event_capacity: usize,
99    recorder_capacity: usize,
100    eval_timeout: std::time::Duration,
101    auth_token: Option<String>,
102    disabled_tools: Vec<String>,
103    command_allowlist: Option<Vec<String>>,
104    command_blocklist: Vec<String>,
105    redaction_patterns: Vec<String>,
106    redaction_enabled: bool,
107    strict_privacy: bool,
108    bridge_capacities: js_bridge::BridgeCapacities,
109    on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
110}
111
112impl Default for VictauriBuilder {
113    fn default() -> Self {
114        Self {
115            port: None,
116            event_capacity: DEFAULT_EVENT_CAPACITY,
117            recorder_capacity: DEFAULT_RECORDER_CAPACITY,
118            eval_timeout: DEFAULT_EVAL_TIMEOUT,
119            auth_token: None,
120            disabled_tools: Vec::new(),
121            command_allowlist: None,
122            command_blocklist: Vec::new(),
123            redaction_patterns: Vec::new(),
124            redaction_enabled: false,
125            strict_privacy: false,
126            bridge_capacities: js_bridge::BridgeCapacities::default(),
127            on_ready: None,
128        }
129    }
130}
131
132impl VictauriBuilder {
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// Set the TCP port for the MCP server (default: 7373, env: `VICTAURI_PORT`).
138    pub fn port(mut self, port: u16) -> Self {
139        self.port = Some(port);
140        self
141    }
142
143    /// Set the maximum number of events in the ring-buffer log (default: 10,000).
144    pub fn event_capacity(mut self, capacity: usize) -> Self {
145        self.event_capacity = capacity;
146        self
147    }
148
149    /// Set the maximum events kept during session recording (default: 50,000).
150    pub fn recorder_capacity(mut self, capacity: usize) -> Self {
151        self.recorder_capacity = capacity;
152        self
153    }
154
155    /// Set the timeout for JavaScript eval operations (default: 30s, env: `VICTAURI_EVAL_TIMEOUT`).
156    pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
157        self.eval_timeout = timeout;
158        self
159    }
160
161    /// Set an explicit auth token for the MCP server (env: `VICTAURI_AUTH_TOKEN`).
162    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
163        self.auth_token = Some(token.into());
164        self
165    }
166
167    /// Generate a random UUID v4 auth token.
168    pub fn generate_auth_token(mut self) -> Self {
169        self.auth_token = Some(auth::generate_token());
170        self
171    }
172
173    /// Disable specific MCP tools by name (e.g., `["eval_js", "screenshot"]`).
174    pub fn disable_tools(mut self, tools: &[&str]) -> Self {
175        self.disabled_tools = tools.iter().map(|s| s.to_string()).collect();
176        self
177    }
178
179    /// Only allow these Tauri commands to be invoked via MCP (positive allowlist).
180    pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
181        self.command_allowlist = Some(commands.iter().map(|s| s.to_string()).collect());
182        self
183    }
184
185    /// Block specific Tauri commands from being invoked via MCP.
186    pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
187        self.command_blocklist = commands.iter().map(|s| s.to_string()).collect();
188        self
189    }
190
191    /// Add a regex pattern for output redaction (e.g., `r"SECRET_\w+"`).
192    pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
193        self.redaction_patterns.push(pattern.into());
194        self
195    }
196
197    /// Enable output redaction with built-in patterns (API keys, emails, tokens).
198    pub fn enable_redaction(mut self) -> Self {
199        self.redaction_enabled = true;
200        self
201    }
202
203    /// Enable strict privacy mode: disables dangerous tools (eval_js, screenshot,
204    /// inject_css, set_storage, delete_storage, navigate, set_dialog_response,
205    /// fill, type_text), enables output redaction with built-in PII patterns.
206    pub fn strict_privacy_mode(mut self) -> Self {
207        self.strict_privacy = true;
208        self
209    }
210
211    /// Set the maximum console log entries kept in the JS bridge (default: 1000).
212    pub fn console_log_capacity(mut self, capacity: usize) -> Self {
213        self.bridge_capacities.console_logs = capacity;
214        self
215    }
216
217    /// Set the maximum network log entries kept in the JS bridge (default: 1000).
218    pub fn network_log_capacity(mut self, capacity: usize) -> Self {
219        self.bridge_capacities.network_log = capacity;
220        self
221    }
222
223    /// Set the maximum navigation log entries kept in the JS bridge (default: 200).
224    pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
225        self.bridge_capacities.navigation_log = capacity;
226        self
227    }
228
229    /// Register a callback invoked once the MCP server is listening.
230    /// The callback receives the port number.
231    pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
232        self.on_ready = Some(Box::new(f));
233        self
234    }
235
236    fn resolve_port(&self) -> u16 {
237        self.port
238            .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
239            .unwrap_or(DEFAULT_PORT)
240    }
241
242    fn resolve_auth_token(&self) -> Option<String> {
243        self.auth_token
244            .clone()
245            .or_else(|| std::env::var("VICTAURI_AUTH_TOKEN").ok())
246    }
247
248    fn resolve_eval_timeout(&self) -> std::time::Duration {
249        std::env::var("VICTAURI_EVAL_TIMEOUT")
250            .ok()
251            .and_then(|s| s.parse::<u64>().ok())
252            .map(std::time::Duration::from_secs)
253            .unwrap_or(self.eval_timeout)
254    }
255
256    fn build_privacy_config(&self) -> privacy::PrivacyConfig {
257        if self.strict_privacy {
258            let mut config = privacy::strict_privacy_config();
259            for cmd in &self.command_blocklist {
260                config.command_blocklist.insert(cmd.clone());
261            }
262            if let Some(ref allow) = self.command_allowlist {
263                config.command_allowlist = Some(allow.iter().cloned().collect());
264            }
265            for tool in &self.disabled_tools {
266                config.disabled_tools.insert(tool.clone());
267            }
268            if !self.redaction_patterns.is_empty() {
269                config.redactor = redaction::Redactor::new(&self.redaction_patterns);
270            }
271            config
272        } else {
273            privacy::PrivacyConfig {
274                command_allowlist: self
275                    .command_allowlist
276                    .as_ref()
277                    .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
278                command_blocklist: self.command_blocklist.iter().cloned().collect(),
279                disabled_tools: self.disabled_tools.iter().cloned().collect(),
280                redactor: redaction::Redactor::new(&self.redaction_patterns),
281                redaction_enabled: self.redaction_enabled,
282            }
283        }
284    }
285
286    fn validate(&self) -> Result<(), BuilderError> {
287        let port = self.resolve_port();
288        if port == 0 {
289            return Err(BuilderError::InvalidPort {
290                port,
291                reason: "port 0 is reserved".to_string(),
292            });
293        }
294
295        if self.event_capacity == 0 || self.event_capacity > 1_000_000 {
296            return Err(BuilderError::InvalidEventCapacity {
297                capacity: self.event_capacity,
298                reason: "must be between 1 and 1,000,000".to_string(),
299            });
300        }
301
302        if self.recorder_capacity == 0 || self.recorder_capacity > 1_000_000 {
303            return Err(BuilderError::InvalidRecorderCapacity {
304                capacity: self.recorder_capacity,
305                reason: "must be between 1 and 1,000,000".to_string(),
306            });
307        }
308
309        let timeout = self.resolve_eval_timeout();
310        if timeout.as_secs() == 0 || timeout.as_secs() > 300 {
311            return Err(BuilderError::InvalidEvalTimeout {
312                timeout_secs: timeout.as_secs(),
313                reason: "must be between 1 and 300 seconds".to_string(),
314            });
315        }
316
317        Ok(())
318    }
319
320    pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
321        #[cfg(not(debug_assertions))]
322        {
323            Ok(Builder::new("victauri").build())
324        }
325
326        #[cfg(debug_assertions)]
327        {
328            self.validate()?;
329
330            let port = self.resolve_port();
331            let event_capacity = self.event_capacity;
332            let recorder_capacity = self.recorder_capacity;
333            let eval_timeout = self.resolve_eval_timeout();
334            let auth_token = self.resolve_auth_token();
335            let privacy_config = self.build_privacy_config();
336            let on_ready = self.on_ready;
337            let js_init = js_bridge::init_script(&self.bridge_capacities);
338
339            Ok(Builder::new("victauri")
340                .setup(move |app, _api| {
341                    let event_log = EventLog::new(event_capacity);
342                    let registry = CommandRegistry::new();
343                    let (shutdown_tx, shutdown_rx) = watch::channel(false);
344
345                    let state = Arc::new(VictauriState {
346                        event_log,
347                        registry,
348                        port,
349                        pending_evals: Arc::new(Mutex::new(HashMap::new())),
350                        recorder: EventRecorder::new(recorder_capacity),
351                        privacy: privacy_config,
352                        eval_timeout,
353                        shutdown_tx,
354                        started_at: std::time::Instant::now(),
355                        tool_invocations: AtomicU64::new(0),
356                    });
357
358                    app.manage(state.clone());
359
360                    if let Some(ref token) = auth_token {
361                        tracing::info!(
362                            "Victauri MCP server auth token: [REDACTED] (check VICTAURI_AUTH_TOKEN env var)"
363                        );
364                        tracing::debug!("Auth token value: {token}");
365                    }
366
367                    let app_handle = app.clone();
368                    tauri::async_runtime::spawn(async move {
369                        match mcp::start_server_with_options(
370                            app_handle, state, port, auth_token, shutdown_rx,
371                        )
372                        .await
373                        {
374                            Ok(()) => {
375                                tracing::info!("Victauri MCP server stopped");
376                            }
377                            Err(e) => {
378                                tracing::error!("Victauri MCP server failed: {e}");
379                            }
380                        }
381                    });
382
383                    if let Some(cb) = on_ready {
384                        let ready_port = port;
385                        tauri::async_runtime::spawn(async move {
386                            for _ in 0..50 {
387                                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
388                                if tokio::net::TcpStream::connect(format!(
389                                    "127.0.0.1:{ready_port}"
390                                ))
391                                .await
392                                .is_ok()
393                                {
394                                    cb(ready_port);
395                                    return;
396                                }
397                            }
398                            tracing::warn!("Victauri on_ready: server did not become ready within 5s");
399                            cb(ready_port);
400                        });
401                    }
402
403                    tracing::info!("Victauri plugin initialized — MCP server on port {port}");
404                    Ok(())
405                })
406                .on_event(|app, event| {
407                    if let RunEvent::Exit = event
408                        && let Some(state) = app.try_state::<Arc<VictauriState>>()
409                    {
410                        let _ = state.shutdown_tx.send(true);
411                        tracing::info!("Victauri shutdown signal sent");
412                    }
413                })
414                .js_init_script(js_init)
415                .invoke_handler(tauri::generate_handler![
416                    tools::victauri_eval_js,
417                    tools::victauri_eval_callback,
418                    tools::victauri_get_window_state,
419                    tools::victauri_list_windows,
420                    tools::victauri_get_ipc_log,
421                    tools::victauri_get_registry,
422                    tools::victauri_get_memory_stats,
423                    tools::victauri_dom_snapshot,
424                    tools::victauri_verify_state,
425                    tools::victauri_detect_ghost_commands,
426                    tools::victauri_check_ipc_integrity,
427                ])
428                .build())
429        }
430    }
431}
432
433/// Initialize the Victauri plugin with default settings (port 7373 or VICTAURI_PORT env var).
434///
435/// In debug builds: starts the embedded MCP server, injects the JS bridge, and
436/// registers all Tauri command handlers.
437///
438/// In release builds: returns a no-op plugin. The MCP server, JS bridge, and
439/// all introspection tools are completely stripped — zero overhead, zero attack surface.
440///
441/// For custom configuration, use `VictauriBuilder::new().port(8080).build()`.
442pub fn init<R: Runtime>() -> TauriPlugin<R> {
443    VictauriBuilder::new()
444        .build()
445        .expect("default Victauri configuration is always valid")
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn builder_default_values() {
454        let builder = VictauriBuilder::new();
455        assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
456        assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
457        assert!(builder.auth_token.is_none());
458        assert!(builder.disabled_tools.is_empty());
459        assert!(builder.command_allowlist.is_none());
460        assert!(builder.command_blocklist.is_empty());
461        assert!(!builder.redaction_enabled);
462        assert!(!builder.strict_privacy);
463    }
464
465    #[test]
466    fn builder_port_override() {
467        let builder = VictauriBuilder::new().port(9090);
468        assert_eq!(builder.resolve_port(), 9090);
469    }
470
471    #[test]
472    fn builder_default_port() {
473        let builder = VictauriBuilder::new();
474        // Clear env var to test default
475        unsafe { std::env::remove_var("VICTAURI_PORT") };
476        assert_eq!(builder.resolve_port(), DEFAULT_PORT);
477    }
478
479    #[test]
480    fn builder_auth_token_explicit() {
481        let builder = VictauriBuilder::new().auth_token("my-secret");
482        assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
483    }
484
485    #[test]
486    fn builder_auth_token_generated() {
487        let builder = VictauriBuilder::new().generate_auth_token();
488        let token = builder.resolve_auth_token().unwrap();
489        assert_eq!(token.len(), 36);
490    }
491
492    #[test]
493    fn builder_capacities() {
494        let builder = VictauriBuilder::new()
495            .event_capacity(500)
496            .recorder_capacity(2000);
497        assert_eq!(builder.event_capacity, 500);
498        assert_eq!(builder.recorder_capacity, 2000);
499    }
500
501    #[test]
502    fn builder_disable_tools() {
503        let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
504        assert_eq!(builder.disabled_tools.len(), 2);
505        assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
506    }
507
508    #[test]
509    fn builder_command_allowlist() {
510        let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
511        assert!(builder.command_allowlist.is_some());
512        assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
513    }
514
515    #[test]
516    fn builder_command_blocklist() {
517        let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
518        assert_eq!(builder.command_blocklist.len(), 1);
519    }
520
521    #[test]
522    fn builder_redaction() {
523        let builder = VictauriBuilder::new()
524            .add_redaction_pattern(r"SECRET_\w+")
525            .enable_redaction();
526        assert!(builder.redaction_enabled);
527        assert_eq!(builder.redaction_patterns.len(), 1);
528    }
529
530    #[test]
531    fn builder_strict_privacy_config() {
532        let builder = VictauriBuilder::new().strict_privacy_mode();
533        let config = builder.build_privacy_config();
534        assert!(config.redaction_enabled);
535        assert!(!config.disabled_tools.is_empty());
536        assert!(config.disabled_tools.contains("eval_js"));
537        assert!(config.disabled_tools.contains("screenshot"));
538    }
539
540    #[test]
541    fn builder_normal_privacy_config() {
542        let builder = VictauriBuilder::new()
543            .command_blocklist(&["secret_cmd"])
544            .disable_tools(&["eval_js"]);
545        let config = builder.build_privacy_config();
546        assert!(config.command_blocklist.contains("secret_cmd"));
547        assert!(config.disabled_tools.contains("eval_js"));
548        assert!(!config.redaction_enabled);
549    }
550
551    #[test]
552    fn builder_strict_with_extra_blocklist() {
553        let builder = VictauriBuilder::new()
554            .strict_privacy_mode()
555            .command_blocklist(&["extra_dangerous"]);
556        let config = builder.build_privacy_config();
557        assert!(config.command_blocklist.contains("extra_dangerous"));
558        assert!(config.disabled_tools.contains("eval_js"));
559    }
560
561    #[test]
562    fn builder_bridge_capacities() {
563        let builder = VictauriBuilder::new()
564            .console_log_capacity(5000)
565            .network_log_capacity(2000)
566            .navigation_log_capacity(500);
567        assert_eq!(builder.bridge_capacities.console_logs, 5000);
568        assert_eq!(builder.bridge_capacities.network_log, 2000);
569        assert_eq!(builder.bridge_capacities.navigation_log, 500);
570        assert_eq!(builder.bridge_capacities.mutation_log, 500);
571        assert_eq!(builder.bridge_capacities.dialog_log, 100);
572    }
573
574    #[test]
575    fn builder_on_ready_sets_callback() {
576        let builder = VictauriBuilder::new().on_ready(|_port| {});
577        assert!(builder.on_ready.is_some());
578    }
579
580    #[test]
581    fn init_script_contains_custom_capacities() {
582        let caps = js_bridge::BridgeCapacities {
583            console_logs: 3000,
584            mutation_log: 750,
585            network_log: 5000,
586            navigation_log: 400,
587            dialog_log: 250,
588            long_tasks: 200,
589        };
590        let script = js_bridge::init_script(&caps);
591        assert!(script.contains("CAP_CONSOLE = 3000"));
592        assert!(script.contains("CAP_MUTATION = 750"));
593        assert!(script.contains("CAP_NETWORK = 5000"));
594        assert!(script.contains("CAP_NAVIGATION = 400"));
595        assert!(script.contains("CAP_DIALOG = 250"));
596        assert!(script.contains("CAP_LONG_TASKS = 200"));
597    }
598
599    #[test]
600    fn init_script_default_contains_standard_capacities() {
601        let caps = js_bridge::BridgeCapacities::default();
602        let script = js_bridge::init_script(&caps);
603        assert!(script.contains("CAP_CONSOLE = 1000"));
604        assert!(script.contains("CAP_NETWORK = 1000"));
605        assert!(script.contains("window.__VICTAURI__"));
606    }
607
608    #[test]
609    fn builder_validates_defaults() {
610        let builder = VictauriBuilder::new();
611        assert!(builder.validate().is_ok());
612    }
613
614    #[test]
615    fn builder_rejects_zero_port() {
616        let builder = VictauriBuilder::new().port(0);
617        let err = builder.validate().unwrap_err();
618        assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
619    }
620
621    #[test]
622    fn builder_rejects_zero_event_capacity() {
623        let builder = VictauriBuilder::new().event_capacity(0);
624        let err = builder.validate().unwrap_err();
625        assert!(matches!(
626            err,
627            BuilderError::InvalidEventCapacity { capacity: 0, .. }
628        ));
629    }
630
631    #[test]
632    fn builder_rejects_excessive_event_capacity() {
633        let builder = VictauriBuilder::new().event_capacity(2_000_000);
634        assert!(builder.validate().is_err());
635    }
636
637    #[test]
638    fn builder_rejects_zero_recorder_capacity() {
639        let builder = VictauriBuilder::new().recorder_capacity(0);
640        assert!(builder.validate().is_err());
641    }
642
643    #[test]
644    fn builder_rejects_zero_eval_timeout() {
645        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
646        assert!(builder.validate().is_err());
647    }
648
649    #[test]
650    fn builder_rejects_excessive_eval_timeout() {
651        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
652        assert!(builder.validate().is_err());
653    }
654
655    #[test]
656    fn builder_accepts_edge_values() {
657        let builder = VictauriBuilder::new()
658            .port(1)
659            .event_capacity(1)
660            .recorder_capacity(1)
661            .eval_timeout(std::time::Duration::from_secs(1));
662        assert!(builder.validate().is_ok());
663
664        let builder = VictauriBuilder::new()
665            .port(65535)
666            .event_capacity(1_000_000)
667            .recorder_capacity(1_000_000)
668            .eval_timeout(std::time::Duration::from_secs(300));
669        assert!(builder.validate().is_ok());
670    }
671}