Skip to main content

victauri_plugin/
lib.rs

1#![deny(missing_docs)]
2//! Victauri — full-stack introspection for Tauri apps via an embedded MCP server.
3//!
4//! Add this plugin to your Tauri app for AI-agent-driven testing and debugging:
5//! DOM snapshots, IPC tracing, cross-boundary verification, and more tools —
6//! all accessible over the Model Context Protocol.
7//!
8//! # Quick Start
9//!
10//! ```ignore
11//! tauri::Builder::default()
12//!     .plugin(victauri_plugin::init())
13//!     .run(tauri::generate_context!())
14//!     .unwrap();
15//! ```
16//!
17//! In debug builds this starts an MCP server on port 7373. In release builds
18//! the plugin is a no-op with zero overhead.
19//!
20//! # Configuration
21//!
22//! Authentication is disabled by default for zero-friction setup. The MCP server
23//! listens on `127.0.0.1` only and is gated behind `#[cfg(debug_assertions)]`.
24//! Use `.auth_token("...")` or `.auth_enabled()` to require a Bearer token.
25//!
26//! ```ignore
27//! tauri::Builder::default()
28//!     .plugin(
29//!         victauri_plugin::VictauriBuilder::new()
30//!             .port(8080)
31//!             .strict_privacy_mode()
32//!             .build(),
33//!     )
34//!     .run(tauri::generate_context!())
35//!     .unwrap();
36//! ```
37
38/// Runtime-erased webview bridge trait and its Tauri implementation.
39pub mod bridge;
40/// Backend database access (`SQLite` read-only queries).
41#[cfg(feature = "sqlite")]
42pub mod database;
43pub mod error;
44/// JS bridge script generation for webview injection.
45pub mod js_bridge;
46/// MCP server, tool handler, and parameter types.
47pub mod mcp;
48mod memory;
49/// Privacy controls: command allowlists, blocklists, and tool disabling.
50pub mod privacy;
51/// Output redaction for API keys, tokens, emails, and sensitive JSON keys.
52pub mod redaction;
53pub(crate) mod screenshot;
54mod tools;
55
56/// Bearer-token authentication, rate limiting, and security middlewares.
57pub mod auth;
58
59use std::collections::{HashMap, HashSet};
60use std::sync::Arc;
61use std::sync::atomic::{AtomicU16, AtomicU64};
62use tauri::plugin::{Builder, TauriPlugin};
63use tauri::{Manager, RunEvent, Runtime};
64use tokio::sync::{Mutex, oneshot, watch};
65use victauri_core::{CommandRegistry, EventLog, EventRecorder};
66
67pub use error::BuilderError;
68pub use privacy::PrivacyProfile;
69
70pub use victauri_core::CommandInfo;
71pub use victauri_macros::inspectable;
72
73/// Register command schemas with the Victauri plugin at app setup time.
74///
75/// Pass the `__schema()` function calls generated by `#[inspectable]`.
76/// This populates the `CommandRegistry` so that `get_registry`, `resolve_command`,
77/// and `detect_ghost_commands` return real results.
78///
79/// # Example
80///
81/// ```rust,ignore
82/// use victauri_plugin::register_commands;
83///
84/// .setup(|app| {
85///     register_commands!(app,
86///         greet__schema(),
87///         increment__schema(),
88///         add_todo__schema(),
89///     );
90///     Ok(())
91/// })
92/// ```
93#[macro_export]
94macro_rules! register_commands {
95    ($app:expr, $($schema_call:expr),+ $(,)?) => {{
96        let state = $app.state::<std::sync::Arc<$crate::VictauriState>>();
97        $(
98            state.registry.register($schema_call);
99        )+
100    }};
101}
102
103const DEFAULT_PORT: u16 = 7373;
104const DEFAULT_EVENT_CAPACITY: usize = 10_000;
105const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
106const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
107const MAX_EVENT_CAPACITY: usize = 1_000_000;
108const MAX_RECORDER_CAPACITY: usize = 1_000_000;
109const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
110
111/// Map of pending JavaScript eval callbacks, keyed by request ID.
112/// Each entry holds a oneshot sender that resolves when the webview returns a result.
113pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
114
115/// Runtime state shared between the MCP server and all tool handlers.
116pub struct VictauriState {
117    /// Ring-buffer event log for IPC calls, state changes, and DOM mutations.
118    pub event_log: EventLog,
119    /// Registry of all discovered Tauri commands with metadata.
120    pub registry: CommandRegistry,
121    /// TCP port the MCP server listens on (may differ from configured port if fallback was used).
122    pub port: AtomicU16,
123    /// Pending JavaScript eval callbacks awaiting webview responses.
124    pub pending_evals: PendingCallbacks,
125    /// Session recorder for time-travel debugging.
126    pub recorder: EventRecorder,
127    /// Privacy configuration (tool disabling, command filtering, output redaction).
128    pub privacy: privacy::PrivacyConfig,
129    /// Timeout for JavaScript eval operations.
130    pub eval_timeout: std::time::Duration,
131    /// Sends `true` to signal graceful MCP server shutdown.
132    pub shutdown_tx: watch::Sender<bool>,
133    /// Instant the plugin was initialized, for uptime tracking.
134    pub started_at: std::time::Instant,
135    /// Total number of MCP tool invocations since startup.
136    pub tool_invocations: AtomicU64,
137    /// Whether `file:` URLs are allowed in the `navigate` tool's `go_to` action.
138    /// Defaults to `false` — only `http` and `https` are permitted unless opted in
139    /// via [`VictauriBuilder::allow_file_navigation`].
140    pub allow_file_navigation: bool,
141}
142
143/// Builder for configuring the Victauri plugin before adding it to a Tauri app.
144///
145/// Supports port selection, authentication, privacy controls, output redaction,
146/// and capacity tuning. All settings have sensible defaults and can be overridden
147/// via environment variables.
148///
149/// **Authentication is disabled by default** for zero-friction local development.
150/// The server binds to `127.0.0.1` only and the entire plugin is `#[cfg(debug_assertions)]`.
151/// Call [`auth_token()`](VictauriBuilder::auth_token) to set an explicit token,
152/// [`auth_enabled()`](VictauriBuilder::auth_enabled) to auto-generate one, or set
153/// the `VICTAURI_AUTH_TOKEN` environment variable.
154pub struct VictauriBuilder {
155    port: Option<u16>,
156    event_capacity: usize,
157    recorder_capacity: usize,
158    eval_timeout: std::time::Duration,
159    auth_token: Option<String>,
160    auth_explicitly_enabled: bool,
161    disabled_tools: Vec<String>,
162    command_allowlist: Option<Vec<String>>,
163    command_blocklist: Vec<String>,
164    redaction_patterns: Vec<String>,
165    redaction_enabled: bool,
166    strict_privacy: bool,
167    privacy_profile: Option<privacy::PrivacyProfile>,
168    bridge_capacities: js_bridge::BridgeCapacities,
169    on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
170    commands: Vec<victauri_core::CommandInfo>,
171    allow_file_navigation: bool,
172}
173
174impl Default for VictauriBuilder {
175    fn default() -> Self {
176        Self {
177            port: None,
178            event_capacity: DEFAULT_EVENT_CAPACITY,
179            recorder_capacity: DEFAULT_RECORDER_CAPACITY,
180            eval_timeout: DEFAULT_EVAL_TIMEOUT,
181            auth_token: None,
182            auth_explicitly_enabled: false,
183            disabled_tools: Vec::new(),
184            command_allowlist: None,
185            command_blocklist: Vec::new(),
186            redaction_patterns: Vec::new(),
187            redaction_enabled: false,
188            strict_privacy: false,
189            privacy_profile: None,
190            bridge_capacities: js_bridge::BridgeCapacities::default(),
191            on_ready: None,
192            commands: Vec::new(),
193            allow_file_navigation: false,
194        }
195    }
196}
197
198impl VictauriBuilder {
199    /// Create a new builder with default settings.
200    #[must_use]
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    /// Set the TCP port for the MCP server (default: 7373, env: `VICTAURI_PORT`).
206    #[must_use]
207    pub fn port(mut self, port: u16) -> Self {
208        self.port = Some(port);
209        self
210    }
211
212    /// Set the maximum number of events in the ring-buffer log (default: 10,000).
213    #[must_use]
214    pub fn event_capacity(mut self, capacity: usize) -> Self {
215        self.event_capacity = capacity;
216        self
217    }
218
219    /// Set the maximum events kept during session recording (default: 50,000).
220    #[must_use]
221    pub fn recorder_capacity(mut self, capacity: usize) -> Self {
222        self.recorder_capacity = capacity;
223        self
224    }
225
226    /// Set the timeout for JavaScript eval operations (default: 30s, env: `VICTAURI_EVAL_TIMEOUT`).
227    #[must_use]
228    pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
229        self.eval_timeout = timeout;
230        self
231    }
232
233    /// Set an explicit auth token for the MCP server (env: `VICTAURI_AUTH_TOKEN`).
234    ///
235    /// Setting a token implicitly enables authentication.
236    #[must_use]
237    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
238        self.auth_token = Some(token.into());
239        self
240    }
241
242    /// Enable authentication with an auto-generated `UUID` v4 token.
243    ///
244    /// The token is printed to the log on startup and written to the discovery
245    /// directory (`<temp>/victauri/<pid>/token`) for client auto-discovery.
246    ///
247    /// Authentication is disabled by default because the MCP server binds to
248    /// `127.0.0.1` only and the plugin is `#[cfg(debug_assertions)]`-gated.
249    /// Enable auth for shared machines or CI environments where multiple
250    /// users may access the same host.
251    #[must_use]
252    pub fn auth_enabled(mut self) -> Self {
253        self.auth_explicitly_enabled = true;
254        self
255    }
256
257    /// Generate a random `UUID` v4 auth token (alias for [`auth_enabled`](Self::auth_enabled)).
258    #[must_use]
259    pub fn generate_auth_token(mut self) -> Self {
260        self.auth_explicitly_enabled = true;
261        self
262    }
263
264    /// No-op — authentication is already disabled by default since v0.4.0.
265    ///
266    /// Kept for backwards compatibility. Previously, auth was enabled by default
267    /// and this method was needed to opt out. Now auth is off by default and
268    /// [`auth_enabled()`](Self::auth_enabled) or [`auth_token()`](Self::auth_token)
269    /// turn it on.
270    #[must_use]
271    pub fn auth_disabled(self) -> Self {
272        self
273    }
274
275    /// Disable specific MCP tools by name (e.g., `["eval_js", "screenshot"]`).
276    #[must_use]
277    pub fn disable_tools(mut self, tools: &[&str]) -> Self {
278        self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
279        self
280    }
281
282    /// Only allow these Tauri commands to be invoked via MCP (positive allowlist).
283    #[must_use]
284    pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
285        self.command_allowlist = Some(
286            commands
287                .iter()
288                .map(std::string::ToString::to_string)
289                .collect(),
290        );
291        self
292    }
293
294    /// Block specific Tauri commands from being invoked via MCP.
295    #[must_use]
296    pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
297        self.command_blocklist = commands
298            .iter()
299            .map(std::string::ToString::to_string)
300            .collect();
301        self
302    }
303
304    /// Add a regex pattern for output redaction (e.g., `r"SECRET_\w+"`).
305    #[must_use]
306    pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
307        self.redaction_patterns.push(pattern.into());
308        self
309    }
310
311    /// Enable output redaction with built-in patterns (API keys, emails, tokens).
312    #[must_use]
313    pub fn enable_redaction(mut self) -> Self {
314        self.redaction_enabled = true;
315        self
316    }
317
318    /// Enable strict privacy mode (equivalent to [`PrivacyProfile::Observe`]).
319    ///
320    /// Disables all mutation tools (`eval_js`, screenshot, interactions, input,
321    /// storage writes, navigation, CSS injection) and enables output redaction.
322    ///
323    /// Prefer [`privacy_profile()`](Self::privacy_profile) for finer control.
324    #[must_use]
325    pub fn strict_privacy_mode(mut self) -> Self {
326        self.strict_privacy = true;
327        self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
328        self
329    }
330
331    /// Set the privacy profile tier.
332    ///
333    /// - [`Observe`](privacy::PrivacyProfile::Observe) — read-only (snapshots, logs, registry)
334    /// - [`Test`](privacy::PrivacyProfile::Test) — observe + interactions + input + storage writes + recording
335    /// - [`FullControl`](privacy::PrivacyProfile::FullControl) — everything (default)
336    ///
337    /// Profiles automatically enable redaction for `Observe` and `Test`.
338    /// Use [`disable_tools()`](Self::disable_tools) to apply overrides on top of a profile.
339    #[must_use]
340    pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
341        self.privacy_profile = Some(profile);
342        if matches!(
343            profile,
344            privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
345        ) {
346            self.redaction_enabled = true;
347        }
348        self
349    }
350
351    /// Set the maximum console log entries kept in the JS bridge (default: 1000).
352    #[must_use]
353    pub fn console_log_capacity(mut self, capacity: usize) -> Self {
354        self.bridge_capacities.console_logs = capacity;
355        self
356    }
357
358    /// Set the maximum network log entries kept in the JS bridge (default: 1000).
359    #[must_use]
360    pub fn network_log_capacity(mut self, capacity: usize) -> Self {
361        self.bridge_capacities.network_log = capacity;
362        self
363    }
364
365    /// Set the maximum navigation log entries kept in the JS bridge (default: 200).
366    #[must_use]
367    pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
368        self.bridge_capacities.navigation_log = capacity;
369        self
370    }
371
372    /// Pre-register command schemas so `get_registry`, `resolve_command`, and
373    /// `detect_ghost_commands` return real results from the moment the server starts.
374    ///
375    /// Pass the `__schema()` functions generated by `#[inspectable]`.
376    ///
377    /// ```rust,ignore
378    /// VictauriBuilder::new()
379    ///     .commands(&[
380    ///         greet__schema(),
381    ///         increment__schema(),
382    ///         add_todo__schema(),
383    ///     ])
384    ///     .build()
385    /// ```
386    #[must_use]
387    pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
388        self.commands = schemas.to_vec();
389        self
390    }
391
392    /// Register command names without full schemas.
393    ///
394    /// Use this when your commands are decorated with `#[tauri::command]` but not
395    /// `#[inspectable]`. Ghost detection will recognize these commands as registered,
396    /// eliminating false positives.
397    ///
398    /// ```rust,ignore
399    /// VictauriBuilder::new()
400    ///     .register_command_names(&[
401    ///         "get_settings",
402    ///         "save_settings",
403    ///         "run_analysis",
404    ///     ])
405    ///     .build()
406    /// ```
407    #[must_use]
408    pub fn register_command_names(mut self, names: &[&str]) -> Self {
409        self.commands
410            .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
411        self
412    }
413
414    /// Auto-discover all `#[inspectable]` commands in the binary.
415    ///
416    /// Uses `inventory` to collect every command marked with `#[inspectable]`
417    /// at link time — no manual listing required. This replaces both
418    /// `.commands(&[...])` and `register_commands!()`.
419    ///
420    /// ```rust,ignore
421    /// tauri::Builder::default()
422    ///     .plugin(
423    ///         VictauriBuilder::new()
424    ///             .auto_discover()
425    ///             .build()
426    ///             .unwrap(),
427    ///     )
428    /// ```
429    #[must_use]
430    pub fn auto_discover(mut self) -> Self {
431        self.commands
432            .extend(victauri_core::auto_discovered_commands());
433        self
434    }
435
436    /// Allow `file:` URLs in the `navigate` tool's `go_to` action.
437    ///
438    /// By default, only `http` and `https` schemes are permitted. Calling this
439    /// method opts in to `file:` navigation, which grants the MCP client access
440    /// to local filesystem paths via the webview.
441    ///
442    /// **Warning:** Enabling this in untrusted environments exposes local files
443    /// to any process that can reach the MCP server.
444    #[must_use]
445    pub fn allow_file_navigation(mut self) -> Self {
446        self.allow_file_navigation = true;
447        self
448    }
449
450    /// Register a callback invoked once the MCP server is listening.
451    /// The callback receives the port number.
452    #[must_use]
453    pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
454        self.on_ready = Some(Box::new(f));
455        self
456    }
457
458    fn resolve_port(&self) -> u16 {
459        self.port
460            .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
461            .unwrap_or(DEFAULT_PORT)
462    }
463
464    fn resolve_auth_token(&self) -> Option<String> {
465        if let Some(ref token) = self.auth_token {
466            return Some(token.clone());
467        }
468        if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
469            return Some(token);
470        }
471        if self.auth_explicitly_enabled {
472            return Some(auth::generate_token());
473        }
474        None
475    }
476
477    fn resolve_eval_timeout(&self) -> std::time::Duration {
478        std::env::var("VICTAURI_EVAL_TIMEOUT")
479            .ok()
480            .and_then(|s| s.parse::<u64>().ok())
481            .map_or(self.eval_timeout, std::time::Duration::from_secs)
482    }
483
484    fn build_privacy_config(&self) -> privacy::PrivacyConfig {
485        let profile = self
486            .privacy_profile
487            .unwrap_or(privacy::PrivacyProfile::FullControl);
488
489        let redaction_enabled = self.redaction_enabled
490            || self.strict_privacy
491            || matches!(
492                profile,
493                privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
494            );
495
496        privacy::PrivacyConfig {
497            profile,
498            command_allowlist: self
499                .command_allowlist
500                .as_ref()
501                .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
502            command_blocklist: self.command_blocklist.iter().cloned().collect(),
503            disabled_tools: self.disabled_tools.iter().cloned().collect(),
504            redactor: redaction::Redactor::new(&self.redaction_patterns),
505            redaction_enabled,
506        }
507    }
508
509    fn validate(&self) -> Result<(), BuilderError> {
510        let port = self.resolve_port();
511        if port == 0 {
512            return Err(BuilderError::InvalidPort {
513                port,
514                reason: "port 0 is reserved".to_string(),
515            });
516        }
517
518        if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
519            return Err(BuilderError::InvalidEventCapacity {
520                capacity: self.event_capacity,
521                reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
522            });
523        }
524
525        if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
526            return Err(BuilderError::InvalidRecorderCapacity {
527                capacity: self.recorder_capacity,
528                reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
529            });
530        }
531
532        let timeout = self.resolve_eval_timeout();
533        if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
534            return Err(BuilderError::InvalidEvalTimeout {
535                timeout_secs: timeout.as_secs(),
536                reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
537            });
538        }
539
540        Ok(())
541    }
542
543    /// Consume the builder and produce a Tauri plugin.
544    ///
545    /// In release builds this always succeeds. In debug builds the builder configuration is
546    /// validated first.
547    ///
548    /// # Errors
549    ///
550    /// Returns [`BuilderError`] if the port, event capacity, recorder capacity, or eval
551    /// timeout are outside their valid ranges (debug builds only).
552    pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
553        #[cfg(not(debug_assertions))]
554        {
555            Ok(Builder::new("victauri").build())
556        }
557
558        #[cfg(debug_assertions)]
559        {
560            self.validate()?;
561
562            let port = self.resolve_port();
563            let event_capacity = self.event_capacity;
564            let recorder_capacity = self.recorder_capacity;
565            let eval_timeout = self.resolve_eval_timeout();
566            let auth_token = self.resolve_auth_token();
567            let privacy_config = self.build_privacy_config();
568            let allow_file_navigation = self.allow_file_navigation;
569            let on_ready = self.on_ready;
570            let commands = self.commands;
571            let js_init = js_bridge::init_script(&self.bridge_capacities);
572
573            Ok(Builder::new("victauri")
574                .setup(move |app, _api| {
575                    let event_log = EventLog::new(event_capacity);
576                    let registry = CommandRegistry::new();
577                    let (shutdown_tx, shutdown_rx) = watch::channel(false);
578
579                    let state = Arc::new(VictauriState {
580                        event_log,
581                        registry,
582                        port: AtomicU16::new(port),
583                        pending_evals: Arc::new(Mutex::new(HashMap::new())),
584                        recorder: EventRecorder::new(recorder_capacity),
585                        privacy: privacy_config,
586                        eval_timeout,
587                        shutdown_tx,
588                        started_at: std::time::Instant::now(),
589                        tool_invocations: AtomicU64::new(0),
590                        allow_file_navigation,
591                    });
592
593                    app.manage(state.clone());
594
595                    for cmd in commands {
596                        state.registry.register(cmd);
597                    }
598
599                    if let Some(ref token) = auth_token {
600                        tracing::info!(
601                            "Victauri MCP server auth enabled — token: {}…{}",
602                            &token[..8],
603                            &token[token.len().saturating_sub(4)..]
604                        );
605                    } else {
606                        tracing::info!(
607                            "Victauri MCP server running without auth (localhost-only, debug build)"
608                        );
609                    }
610
611                    let app_handle = app.clone();
612                    let ready_state = state.clone();
613                    tauri::async_runtime::spawn(async move {
614                        match mcp::start_server_with_options(
615                            app_handle,
616                            state,
617                            port,
618                            auth_token,
619                            shutdown_rx,
620                        )
621                        .await
622                        {
623                            Ok(()) => {
624                                tracing::info!("Victauri MCP server stopped");
625                            }
626                            Err(e) => {
627                                tracing::error!("Victauri MCP server failed: {e}");
628                            }
629                        }
630                    });
631
632                    if let Some(cb) = on_ready {
633                        tauri::async_runtime::spawn(async move {
634                            for _ in 0..50 {
635                                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
636                                let actual_port =
637                                    ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
638                                if tokio::net::TcpStream::connect(format!(
639                                    "127.0.0.1:{actual_port}"
640                                ))
641                                .await
642                                .is_ok()
643                                {
644                                    cb(actual_port);
645                                    return;
646                                }
647                            }
648                            let actual_port =
649                                ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
650                            tracing::warn!(
651                                "Victauri on_ready: server did not become ready within 5s"
652                            );
653                            cb(actual_port);
654                        });
655                    }
656
657                    tracing::info!("Victauri plugin initialized — MCP server on port {port}");
658                    Ok(())
659                })
660                .on_event(|app, event| {
661                    if let RunEvent::Exit = event
662                        && let Some(state) = app.try_state::<Arc<VictauriState>>()
663                    {
664                        let _ = state.shutdown_tx.send(true);
665                        tracing::info!("Victauri shutdown signal sent");
666                    }
667                })
668                .js_init_script(js_init)
669                .invoke_handler(tauri::generate_handler![
670                    tools::victauri_eval_js,
671                    tools::victauri_eval_callback,
672                    tools::victauri_get_window_state,
673                    tools::victauri_list_windows,
674                    tools::victauri_get_ipc_log,
675                    tools::victauri_get_registry,
676                    tools::victauri_get_memory_stats,
677                    tools::victauri_dom_snapshot,
678                    tools::victauri_verify_state,
679                    tools::victauri_detect_ghost_commands,
680                    tools::victauri_check_ipc_integrity,
681                ])
682                .build())
683        }
684    }
685}
686
687/// Initialize the Victauri plugin with default settings (port 7373 or `VICTAURI_PORT` env var).
688///
689/// In debug builds: starts the embedded MCP server, injects the JS bridge, and
690/// registers all Tauri command handlers.
691///
692/// In release builds: returns a no-op plugin. The MCP server, JS bridge, and
693/// all introspection tools are completely stripped — zero overhead, zero attack surface.
694///
695/// For custom configuration, use `VictauriBuilder::new().port(8080).build()`.
696///
697/// # Panics
698///
699/// Panics if the default builder configuration is invalid (this is a bug).
700#[must_use]
701pub fn init<R: Runtime>() -> TauriPlugin<R> {
702    VictauriBuilder::new()
703        .build()
704        .expect("default Victauri configuration is always valid")
705}
706
707/// Initialize the Victauri plugin with auto-discovery of all `#[inspectable]` commands.
708///
709/// Equivalent to `VictauriBuilder::new().auto_discover().build()` — all commands
710/// marked with `#[inspectable]` are registered automatically without manual listing.
711///
712/// # Panics
713///
714/// Panics if the default builder configuration is invalid (this is a bug).
715#[must_use]
716pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
717    VictauriBuilder::new()
718        .auto_discover()
719        .build()
720        .expect("default Victauri configuration is always valid")
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    #[test]
728    fn builder_default_values() {
729        let builder = VictauriBuilder::new();
730        assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
731        assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
732        assert!(builder.auth_token.is_none());
733        assert!(!builder.auth_explicitly_enabled);
734        let resolved = builder.resolve_auth_token();
735        assert!(resolved.is_none(), "auth should be disabled by default");
736        assert!(builder.disabled_tools.is_empty());
737        assert!(builder.command_allowlist.is_none());
738        assert!(builder.command_blocklist.is_empty());
739        assert!(!builder.redaction_enabled);
740        assert!(!builder.strict_privacy);
741    }
742
743    #[test]
744    fn builder_port_override() {
745        let builder = VictauriBuilder::new().port(9090);
746        assert_eq!(builder.resolve_port(), 9090);
747    }
748
749    #[test]
750    #[allow(unsafe_code)]
751    fn builder_default_port() {
752        let builder = VictauriBuilder::new();
753        // SAFETY: test-only — no concurrent env reads in this test binary.
754        unsafe { std::env::remove_var("VICTAURI_PORT") };
755        assert_eq!(builder.resolve_port(), DEFAULT_PORT);
756    }
757
758    #[test]
759    fn builder_auth_token_explicit() {
760        let builder = VictauriBuilder::new().auth_token("my-secret");
761        assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
762    }
763
764    #[test]
765    fn builder_auth_enabled() {
766        let builder = VictauriBuilder::new().auth_enabled();
767        assert!(builder.auth_explicitly_enabled);
768        let token = builder.resolve_auth_token().unwrap();
769        assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
770    }
771
772    #[test]
773    fn builder_auth_generate_token() {
774        let builder = VictauriBuilder::new().generate_auth_token();
775        let token = builder.resolve_auth_token().unwrap();
776        assert_eq!(token.len(), 36);
777    }
778
779    #[test]
780    fn builder_auth_disabled_is_noop() {
781        let builder = VictauriBuilder::new().auth_disabled();
782        assert!(
783            builder.resolve_auth_token().is_none(),
784            "auth_disabled is a no-op, auth stays off by default"
785        );
786    }
787
788    #[test]
789    fn builder_auth_disabled_does_not_override_explicit_token() {
790        let builder = VictauriBuilder::new()
791            .auth_token("my-secret")
792            .auth_disabled();
793        assert_eq!(
794            builder.resolve_auth_token(),
795            Some("my-secret".to_string()),
796            "auth_disabled is a no-op, explicit token should remain"
797        );
798    }
799
800    #[test]
801    fn builder_capacities() {
802        let builder = VictauriBuilder::new()
803            .event_capacity(500)
804            .recorder_capacity(2000);
805        assert_eq!(builder.event_capacity, 500);
806        assert_eq!(builder.recorder_capacity, 2000);
807    }
808
809    #[test]
810    fn builder_disable_tools() {
811        let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
812        assert_eq!(builder.disabled_tools.len(), 2);
813        assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
814    }
815
816    #[test]
817    fn builder_command_allowlist() {
818        let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
819        assert!(builder.command_allowlist.is_some());
820        assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
821    }
822
823    #[test]
824    fn builder_command_blocklist() {
825        let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
826        assert_eq!(builder.command_blocklist.len(), 1);
827    }
828
829    #[test]
830    fn builder_redaction() {
831        let builder = VictauriBuilder::new()
832            .add_redaction_pattern(r"SECRET_\w+")
833            .enable_redaction();
834        assert!(builder.redaction_enabled);
835        assert_eq!(builder.redaction_patterns.len(), 1);
836    }
837
838    #[test]
839    fn builder_strict_privacy_config() {
840        let builder = VictauriBuilder::new().strict_privacy_mode();
841        let config = builder.build_privacy_config();
842        assert!(config.redaction_enabled);
843        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
844        assert!(!config.is_tool_enabled("eval_js"));
845        assert!(!config.is_tool_enabled("screenshot"));
846        assert!(!config.is_tool_enabled("interact"));
847        assert!(config.is_tool_enabled("dom_snapshot"));
848    }
849
850    #[test]
851    fn builder_normal_privacy_config() {
852        let builder = VictauriBuilder::new()
853            .command_blocklist(&["secret_cmd"])
854            .disable_tools(&["eval_js"]);
855        let config = builder.build_privacy_config();
856        assert!(config.command_blocklist.contains("secret_cmd"));
857        assert!(!config.is_tool_enabled("eval_js"));
858        assert!(!config.redaction_enabled);
859    }
860
861    #[test]
862    fn builder_strict_with_extra_blocklist() {
863        let builder = VictauriBuilder::new()
864            .strict_privacy_mode()
865            .command_blocklist(&["extra_dangerous"]);
866        let config = builder.build_privacy_config();
867        assert!(config.command_blocklist.contains("extra_dangerous"));
868        assert!(!config.is_tool_enabled("eval_js"));
869    }
870
871    #[test]
872    fn builder_test_profile() {
873        let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
874        let config = builder.build_privacy_config();
875        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
876        assert!(config.redaction_enabled);
877        assert!(config.is_tool_enabled("interact"));
878        assert!(config.is_tool_enabled("fill"));
879        assert!(config.is_tool_enabled("recording"));
880        assert!(!config.is_tool_enabled("eval_js"));
881        assert!(!config.is_tool_enabled("screenshot"));
882        assert!(!config.is_tool_enabled("navigate"));
883    }
884
885    #[test]
886    fn builder_profile_with_extra_disables() {
887        let builder = VictauriBuilder::new()
888            .privacy_profile(crate::privacy::PrivacyProfile::Test)
889            .disable_tools(&["interact"]);
890        let config = builder.build_privacy_config();
891        assert!(!config.is_tool_enabled("interact"));
892        assert!(config.is_tool_enabled("fill"));
893    }
894
895    #[test]
896    fn builder_bridge_capacities() {
897        let builder = VictauriBuilder::new()
898            .console_log_capacity(5000)
899            .network_log_capacity(2000)
900            .navigation_log_capacity(500);
901        assert_eq!(builder.bridge_capacities.console_logs, 5000);
902        assert_eq!(builder.bridge_capacities.network_log, 2000);
903        assert_eq!(builder.bridge_capacities.navigation_log, 500);
904        assert_eq!(builder.bridge_capacities.mutation_log, 500);
905        assert_eq!(builder.bridge_capacities.dialog_log, 100);
906    }
907
908    #[test]
909    fn builder_on_ready_sets_callback() {
910        let builder = VictauriBuilder::new().on_ready(|_port| {});
911        assert!(builder.on_ready.is_some());
912    }
913
914    #[test]
915    fn builder_file_navigation_disabled_by_default() {
916        let builder = VictauriBuilder::new();
917        assert!(
918            !builder.allow_file_navigation,
919            "file navigation should be disabled by default"
920        );
921    }
922
923    #[test]
924    fn builder_allow_file_navigation() {
925        let builder = VictauriBuilder::new().allow_file_navigation();
926        assert!(builder.allow_file_navigation);
927    }
928
929    #[test]
930    fn init_script_contains_custom_capacities() {
931        let caps = js_bridge::BridgeCapacities {
932            console_logs: 3000,
933            mutation_log: 750,
934            network_log: 5000,
935            navigation_log: 400,
936            dialog_log: 250,
937            long_tasks: 200,
938        };
939        let script = js_bridge::init_script(&caps);
940        assert!(script.contains("CAP_CONSOLE = 3000"));
941        assert!(script.contains("CAP_MUTATION = 750"));
942        assert!(script.contains("CAP_NETWORK = 5000"));
943        assert!(script.contains("CAP_NAVIGATION = 400"));
944        assert!(script.contains("CAP_DIALOG = 250"));
945        assert!(script.contains("CAP_LONG_TASKS = 200"));
946    }
947
948    #[test]
949    fn init_script_default_contains_standard_capacities() {
950        let caps = js_bridge::BridgeCapacities::default();
951        let script = js_bridge::init_script(&caps);
952        assert!(script.contains("CAP_CONSOLE = 1000"));
953        assert!(script.contains("CAP_NETWORK = 1000"));
954        assert!(script.contains("window.__VICTAURI__"));
955    }
956
957    #[test]
958    fn builder_validates_defaults() {
959        let builder = VictauriBuilder::new();
960        assert!(builder.validate().is_ok());
961    }
962
963    #[test]
964    fn builder_rejects_zero_port() {
965        let builder = VictauriBuilder::new().port(0);
966        let err = builder.validate().unwrap_err();
967        assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
968    }
969
970    #[test]
971    fn builder_rejects_zero_event_capacity() {
972        let builder = VictauriBuilder::new().event_capacity(0);
973        let err = builder.validate().unwrap_err();
974        assert!(matches!(
975            err,
976            BuilderError::InvalidEventCapacity { capacity: 0, .. }
977        ));
978    }
979
980    #[test]
981    fn builder_rejects_excessive_event_capacity() {
982        let builder = VictauriBuilder::new().event_capacity(2_000_000);
983        assert!(builder.validate().is_err());
984    }
985
986    #[test]
987    fn builder_rejects_zero_recorder_capacity() {
988        let builder = VictauriBuilder::new().recorder_capacity(0);
989        assert!(builder.validate().is_err());
990    }
991
992    #[test]
993    fn builder_rejects_zero_eval_timeout() {
994        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
995        assert!(builder.validate().is_err());
996    }
997
998    #[test]
999    fn builder_rejects_excessive_eval_timeout() {
1000        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1001        assert!(builder.validate().is_err());
1002    }
1003
1004    #[test]
1005    fn builder_accepts_edge_values() {
1006        let builder = VictauriBuilder::new()
1007            .port(1)
1008            .event_capacity(1)
1009            .recorder_capacity(1)
1010            .eval_timeout(std::time::Duration::from_secs(1));
1011        assert!(builder.validate().is_ok());
1012
1013        let builder = VictauriBuilder::new()
1014            .port(65535)
1015            .event_capacity(MAX_EVENT_CAPACITY)
1016            .recorder_capacity(MAX_RECORDER_CAPACITY)
1017            .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1018        assert!(builder.validate().is_ok());
1019    }
1020}