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