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