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