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