Skip to main content

victauri_plugin/
lib.rs

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