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