Skip to main content

victauri_plugin/
lib.rs

1#![deny(missing_docs)]
2// In RELEASE builds the entire MCP server is gated out via `#[cfg(debug_assertions)]`
3// (`init()` is a zero-cost no-op), so the imports/constants/helpers it uses are
4// intentionally dead. Don't fail a `-Dwarnings` release build on that expected
5// dead code — without this, `RUSTFLAGS=-Dwarnings cargo build --release` errors.
6#![cfg_attr(not(debug_assertions), allow(unused_imports, dead_code))]
7//! Victauri — full-stack introspection for Tauri apps via an embedded MCP server.
8//!
9//! Add this plugin to your Tauri app for AI-agent-driven testing and debugging:
10//! DOM snapshots, IPC tracing, cross-boundary verification, and more tools —
11//! all accessible over the Model Context Protocol.
12//!
13//! # Quick Start
14//!
15//! ```ignore
16//! tauri::Builder::default()
17//!     .plugin(victauri_plugin::init())
18//!     .run(tauri::generate_context!())
19//!     .unwrap();
20//! ```
21//!
22//! In debug builds this starts an MCP server on port 7373. In release builds
23//! the plugin is a no-op with zero overhead.
24//!
25//! # Configuration
26//!
27//! Authentication is **enabled by default** with an auto-generated Bearer token
28//! written to the per-user discovery directory (`<root>/<pid>/token`; the root is `%TEMP%\victauri` on
29//! Windows, and on Unix `$XDG_RUNTIME_DIR/victauri`, `<temp>/victauri-<uid>` or
30//! `~/.local/state/victauri`) for client
31//! auto-discovery. The MCP
32//! server listens on `127.0.0.1` only and is gated behind `#[cfg(debug_assertions)]`.
33//! Use `.auth_token("...")` for a fixed token, or `.auth_disabled()` to opt out.
34//!
35//! ```ignore
36//! tauri::Builder::default()
37//!     .plugin(
38//!         victauri_plugin::VictauriBuilder::new()
39//!             .port(8080)
40//!             .strict_privacy_mode()
41//!             .build(),
42//!     )
43//!     .run(tauri::generate_context!())
44//!     .unwrap();
45//! ```
46
47/// Runtime-erased webview bridge trait and its Tauri implementation.
48pub mod bridge;
49/// Backend database access (`SQLite` read-only queries).
50///
51/// Internal: reached through the `query_db` / `introspect db_health` tools. Not
52/// part of the supported API.
53#[cfg(feature = "sqlite")]
54#[doc(hidden)]
55pub mod database;
56pub mod error;
57/// JS bridge script generation for webview injection.
58///
59/// Internal: public only so the crate's own integration tests can exercise the
60/// generated script. Not part of the supported API.
61#[doc(hidden)]
62pub mod js_bridge;
63/// MCP server, tool handler, and parameter types.
64pub mod mcp;
65mod memory;
66/// Privacy controls: command allowlists, blocklists, and tool disabling.
67pub mod privacy;
68
69/// Compose captured frames into a single contact-sheet PNG ("filmstrip").
70pub(crate) mod filmstrip;
71/// Output redaction for API keys, tokens, emails, and sensitive JSON keys.
72///
73/// Public because [`privacy::PrivacyConfig::redactor`] exposes a [`redaction::Redactor`].
74pub mod redaction;
75/// Screencast ring buffer backing the `trace` tool.
76///
77/// Internal: public only because [`VictauriState::screencast`] is. Not part of
78/// the supported API.
79#[doc(hidden)]
80pub mod screencast;
81pub(crate) mod screenshot;
82mod tools;
83
84/// Bearer-token authentication, rate limiting, and security middlewares.
85pub mod auth;
86/// Backend introspection and chaos engineering (profiling, fault injection, contracts).
87pub mod introspection;
88
89use std::collections::{HashMap, HashSet};
90use std::sync::Arc;
91use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64};
92use tauri::plugin::{Builder, TauriPlugin};
93use tauri::{Listener, Manager, RunEvent, Runtime};
94use tokio::sync::{Mutex, oneshot, watch};
95use victauri_core::{CommandRegistry, EventLog, EventRecorder};
96
97pub use error::BuilderError;
98pub use privacy::PrivacyProfile;
99
100pub use victauri_core::CommandInfo;
101pub use victauri_macros::inspectable;
102
103/// Register command schemas with the Victauri plugin at app setup time.
104///
105/// Pass the `__schema()` function calls generated by `#[inspectable]`.
106/// This populates the `CommandRegistry` so that `get_registry`, `resolve_command`,
107/// and `detect_ghost_commands` return real results.
108///
109/// # Example
110///
111/// ```rust,ignore
112/// use victauri_plugin::register_commands;
113///
114/// .setup(|app| {
115///     register_commands!(app,
116///         greet__schema(),
117///         increment__schema(),
118///         add_todo__schema(),
119///     );
120///     Ok(())
121/// })
122/// ```
123#[macro_export]
124macro_rules! register_commands {
125    ($app:expr, $($schema_call:expr),+ $(,)?) => {{
126        // `try_state` (not `state`) so this is a no-op in RELEASE builds: there
127        // `init()` returns a no-op plugin that never manages `VictauriState`, and
128        // `state()` would PANIC on the missing type. Registration is debug-only.
129        if let Some(state) = $app.try_state::<std::sync::Arc<$crate::VictauriState>>() {
130            $(
131                state.registry.register($schema_call);
132            )+
133        }
134    }};
135}
136
137const DEFAULT_PORT: u16 = 7373;
138const DEFAULT_EVENT_CAPACITY: usize = 10_000;
139const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
140const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
141const MAX_EVENT_CAPACITY: usize = 1_000_000;
142const MAX_RECORDER_CAPACITY: usize = 1_000_000;
143const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
144
145/// Map of pending JavaScript eval callbacks, keyed by request ID.
146/// Each entry holds a oneshot sender that resolves when the webview returns a result.
147///
148/// Internal plumbing (the type of [`VictauriState::pending_evals`]); not part of
149/// the supported API.
150#[doc(hidden)]
151pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
152
153/// A reserved [`PendingCallbacks`] entry, removed when the guard is dropped.
154///
155/// Every exit path of an eval must release its slot, including the one no code runs on: a REST
156/// tool call executes inside the axum future, so a client that disconnects or times out DROPS
157/// the call mid-wait. Removing the entry only on the timeout/error paths leaked it then, and 100
158/// leaked entries failed every later eval with "too many concurrent" until the app restarted.
159pub(crate) struct PendingSlot {
160    map: PendingCallbacks,
161    id: String,
162}
163
164impl PendingSlot {
165    /// Insert `tx` under `id` into an already-locked map (the caller checked the ceilings under
166    /// the same lock).
167    pub(crate) fn insert(
168        map: &PendingCallbacks,
169        locked: &mut HashMap<String, oneshot::Sender<String>>,
170        id: String,
171        tx: oneshot::Sender<String>,
172    ) -> Self {
173        locked.insert(id.clone(), tx);
174        Self {
175            map: Arc::clone(map),
176            id,
177        }
178    }
179
180    /// The reserved eval id.
181    pub(crate) fn id(&self) -> &str {
182        &self.id
183    }
184
185    /// Record which window this eval's result must come from: `Some(label)` for that window,
186    /// `None` for the default window (resolved when the result arrives). Call before the eval
187    /// is delivered — a result for an unbound eval is refused (see [`eval_result_accepted`]).
188    /// May be called again to narrow `None` to the label the eval actually resolved to.
189    pub(crate) fn bind_window(&self, label: Option<&str>) {
190        let target = label.map_or(EvalTarget::DefaultWindow, |l| {
191            EvalTarget::Window(l.to_string())
192        });
193        eval_targets().insert(self.id.clone(), target);
194    }
195}
196
197/// The window an eval's result must come from.
198#[derive(Clone, Debug, PartialEq, Eq)]
199enum EvalTarget {
200    /// This window.
201    Window(String),
202    /// The default window, as resolved when the result arrives.
203    DefaultWindow,
204}
205
206/// Eval id -> the window its result must come from (entries live as long as their
207/// [`PendingSlot`]). Process-wide: eval ids are UUIDs, unique across every state.
208static EVAL_TARGETS: std::sync::LazyLock<std::sync::Mutex<HashMap<String, EvalTarget>>> =
209    std::sync::LazyLock::new(Default::default);
210
211fn eval_targets() -> std::sync::MutexGuard<'static, HashMap<String, EvalTarget>> {
212    EVAL_TARGETS
213        .lock()
214        .unwrap_or_else(std::sync::PoisonError::into_inner)
215}
216
217/// Whether an eval result for `id` arriving from webview `caller` may be delivered: only from
218/// the window the eval was sent to (R5B-EVALWIN1). `default_label` resolves the default window
219/// (only called for an eval sent to it). A result for an id with no binding — not a pending eval,
220/// or one that was never bound — is refused.
221pub(crate) fn eval_result_accepted(
222    id: &str,
223    caller: &str,
224    default_label: impl FnOnce() -> Option<String>,
225) -> bool {
226    let target = eval_targets().get(id).cloned();
227    match target {
228        Some(EvalTarget::Window(label)) => label == caller,
229        Some(EvalTarget::DefaultWindow) => default_label().as_deref() == Some(caller),
230        None => false,
231    }
232}
233
234impl Drop for PendingSlot {
235    fn drop(&mut self) {
236        eval_targets().remove(&self.id);
237        // The map's lock is async and only held for map operations, so it is almost always
238        // free; when it is not, finish the removal on the runtime (or block, outside one).
239        if let Ok(mut map) = self.map.try_lock() {
240            map.remove(&self.id);
241            return;
242        }
243        let map = Arc::clone(&self.map);
244        let id = std::mem::take(&mut self.id);
245        match tokio::runtime::Handle::try_current() {
246            Ok(rt) => {
247                rt.spawn(async move {
248                    map.lock().await.remove(&id);
249                });
250            }
251            Err(_) => {
252                map.blocking_lock().remove(&id);
253            }
254        }
255    }
256}
257
258/// Runtime state shared between the MCP server and all tool handlers.
259///
260/// Built by the plugin at setup; apps reach it via
261/// `app.try_state::<Arc<VictauriState>>()` (see [`register_commands!`]).
262/// `#[non_exhaustive]`: new state can be added without a breaking release.
263#[non_exhaustive]
264pub struct VictauriState {
265    /// Ring-buffer event log for IPC calls, state changes, and DOM mutations.
266    pub event_log: EventLog,
267    /// Registry of all discovered Tauri commands with metadata.
268    pub registry: CommandRegistry,
269    /// TCP port the MCP server listens on (may differ from configured port if fallback was used).
270    pub port: AtomicU16,
271    /// Pending JavaScript eval callbacks awaiting webview responses.
272    pub pending_evals: PendingCallbacks,
273    /// Session recorder for time-travel debugging.
274    pub recorder: EventRecorder,
275    /// Privacy configuration (tool disabling, command filtering, output redaction).
276    pub privacy: privacy::PrivacyConfig,
277    /// Timeout for JavaScript eval operations.
278    pub eval_timeout: std::time::Duration,
279    /// Sends `true` to signal graceful MCP server shutdown.
280    pub shutdown_tx: watch::Sender<bool>,
281    /// Instant the plugin was initialized, for uptime tracking.
282    pub started_at: std::time::Instant,
283    /// Total number of MCP tool invocations since startup.
284    pub tool_invocations: AtomicU64,
285    /// Whether `file:` URLs are allowed in the `navigate` tool's `go_to` action.
286    /// Defaults to `false` — only `http` and `https` are permitted unless opted in
287    /// via [`VictauriBuilder::allow_file_navigation`].
288    pub allow_file_navigation: bool,
289    /// Per-command execution timing for Rust-side profiling.
290    pub command_timings: introspection::CommandTimings,
291    /// Active fault injection rules for chaos engineering.
292    pub fault_registry: introspection::FaultRegistry,
293    /// Recorded IPC contract baselines for schema drift detection.
294    pub contract_store: introspection::ContractStore,
295    /// Plugin startup phase timestamps for performance analysis.
296    pub startup_timeline: introspection::StartupTimeline,
297    /// Captured Tauri event bus events (from `listen_any`).
298    pub event_bus: introspection::EventBusMonitor,
299    /// Tracker for Victauri's own spawned async tasks.
300    pub task_tracker: introspection::TaskTracker,
301    /// Set to `true` when any webview's JS bridge sends its ready signal.
302    pub bridge_ready: AtomicBool,
303    /// Notifies waiters when the JS bridge ready signal arrives.
304    pub bridge_notify: tokio::sync::Notify,
305    /// Screencast state for the `trace` tool (background frame capture buffer).
306    pub screencast: Arc<screencast::Screencast>,
307    /// Extra absolute directories searched by `query_db` / `introspect db_health`
308    /// in addition to the OS app directories. Lets Victauri reach databases an
309    /// app stores outside the Tauri app-data dir (e.g. a project or working
310    /// directory). Opt in via [`VictauriBuilder::db_search_paths`]. Absolute
311    /// `query_db` paths are permitted only when they resolve within one of these
312    /// roots (or an app directory).
313    pub db_search_paths: Vec<std::path::PathBuf>,
314    /// Application-defined state probes surfaced via the `app_state` tool.
315    /// Registered through [`VictauriBuilder::probe`].
316    pub probes: introspection::AppStateProbes,
317    /// Shared per-window watermarks for draining JS events into a recording.
318    pub(crate) drain_watermarks: introspection::DrainWatermarks,
319    /// Per-window page-load generations (bumped by each bridge ready signal).
320    pub(crate) page_loads: introspection::PageLoads,
321}
322
323impl VictauriState {
324    /// A standalone state with test-friendly defaults, for driving the MCP
325    /// router (`mcp::build_app*`) without a Tauri runtime.
326    ///
327    /// Defaults: 1000-event log and recorder, port 0, default (full-control)
328    /// privacy, 30 s eval timeout, bridge already marked ready, no db search
329    /// paths or probes. All fields are `pub`, so adjust any of them on the
330    /// returned value before wrapping it in an `Arc`.
331    ///
332    /// Not part of the supported API — it exists so out-of-crate tests can build
333    /// the (`#[non_exhaustive]`) state.
334    #[doc(hidden)]
335    #[must_use]
336    pub fn for_tests() -> Self {
337        Self {
338            event_log: EventLog::new(1000),
339            registry: CommandRegistry::new(),
340            port: AtomicU16::new(0),
341            pending_evals: Arc::new(Mutex::new(HashMap::new())),
342            recorder: EventRecorder::new(1000),
343            privacy: privacy::PrivacyConfig::default(),
344            eval_timeout: std::time::Duration::from_secs(30),
345            shutdown_tx: watch::channel(false).0,
346            started_at: std::time::Instant::now(),
347            tool_invocations: AtomicU64::new(0),
348            allow_file_navigation: false,
349            command_timings: introspection::CommandTimings::new(),
350            fault_registry: introspection::FaultRegistry::new(),
351            contract_store: introspection::ContractStore::new(),
352            startup_timeline: introspection::StartupTimeline::new(),
353            event_bus: introspection::EventBusMonitor::default(),
354            task_tracker: introspection::TaskTracker::new(),
355            bridge_ready: AtomicBool::new(true),
356            bridge_notify: tokio::sync::Notify::new(),
357            screencast: Arc::new(screencast::Screencast::default()),
358            db_search_paths: Vec::new(),
359            probes: introspection::AppStateProbes::default(),
360            drain_watermarks: introspection::DrainWatermarks::default(),
361            page_loads: introspection::PageLoads::default(),
362        }
363    }
364}
365
366/// Builder for configuring the Victauri plugin before adding it to a Tauri app.
367///
368/// Supports port selection, authentication, privacy controls, output redaction,
369/// and capacity tuning. All settings have sensible defaults and can be overridden
370/// via environment variables.
371///
372/// **Authentication is enabled by default** with an auto-generated token written to
373/// the per-user discovery directory (`<root>/<pid>/token`; the root is `%TEMP%\victauri` on
374/// Windows, and on Unix `$XDG_RUNTIME_DIR/victauri`, `<temp>/victauri-<uid>` or
375/// `~/.local/state/victauri`). MCP clients that read
376/// this file get seamless access. Set `VICTAURI_AUTH_TOKEN` for a fixed token, or
377/// call [`auth_disabled()`](VictauriBuilder::auth_disabled) to opt out for local-only
378/// single-user development.
379pub struct VictauriBuilder {
380    port: Option<u16>,
381    event_capacity: usize,
382    recorder_capacity: usize,
383    eval_timeout: std::time::Duration,
384    auth_token: Option<String>,
385    auth_explicitly_enabled: bool,
386    auth_explicitly_disabled: bool,
387    disabled_tools: Vec<String>,
388    command_allowlist: Option<Vec<String>>,
389    command_blocklist: Vec<String>,
390    storage_key_blocklist: Vec<String>,
391    redaction_patterns: Vec<String>,
392    redaction_enabled: bool,
393    strict_privacy: bool,
394    privacy_profile: Option<privacy::PrivacyProfile>,
395    bridge_capacities: js_bridge::BridgeCapacities,
396    on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
397    commands: Vec<victauri_core::CommandInfo>,
398    allow_file_navigation: bool,
399    listen_events: Vec<String>,
400    db_search_paths: Vec<std::path::PathBuf>,
401    probes: Vec<(String, std::sync::Arc<introspection::ProbeFn>)>,
402}
403
404impl Default for VictauriBuilder {
405    fn default() -> Self {
406        Self {
407            port: None,
408            event_capacity: DEFAULT_EVENT_CAPACITY,
409            recorder_capacity: DEFAULT_RECORDER_CAPACITY,
410            eval_timeout: DEFAULT_EVAL_TIMEOUT,
411            auth_token: None,
412            auth_explicitly_enabled: false,
413            auth_explicitly_disabled: false,
414            disabled_tools: Vec::new(),
415            command_allowlist: None,
416            command_blocklist: Vec::new(),
417            storage_key_blocklist: Vec::new(),
418            redaction_patterns: Vec::new(),
419            redaction_enabled: false,
420            strict_privacy: false,
421            privacy_profile: None,
422            bridge_capacities: js_bridge::BridgeCapacities::default(),
423            on_ready: None,
424            commands: Vec::new(),
425            allow_file_navigation: false,
426            listen_events: Vec::new(),
427            db_search_paths: Vec::new(),
428            probes: Vec::new(),
429        }
430    }
431}
432
433impl VictauriBuilder {
434    /// Create a new builder with default settings.
435    #[must_use]
436    pub fn new() -> Self {
437        Self::default()
438    }
439
440    /// Set the TCP port for the MCP server (default: 7373, env: `VICTAURI_PORT`).
441    #[must_use]
442    pub fn port(mut self, port: u16) -> Self {
443        self.port = Some(port);
444        self
445    }
446
447    /// Set the maximum number of events in the ring-buffer log (default: 10,000).
448    #[must_use]
449    pub fn event_capacity(mut self, capacity: usize) -> Self {
450        self.event_capacity = capacity;
451        self
452    }
453
454    /// Set the maximum events kept during session recording (default: 50,000).
455    #[must_use]
456    pub fn recorder_capacity(mut self, capacity: usize) -> Self {
457        self.recorder_capacity = capacity;
458        self
459    }
460
461    /// Set the timeout for JavaScript eval operations (default: 30s, env: `VICTAURI_EVAL_TIMEOUT`).
462    #[must_use]
463    pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
464        self.eval_timeout = timeout;
465        self
466    }
467
468    /// Set an explicit auth token for the MCP server (env: `VICTAURI_AUTH_TOKEN`).
469    ///
470    /// Setting a token implicitly enables authentication.
471    #[must_use]
472    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
473        self.auth_token = Some(token.into());
474        self
475    }
476
477    /// Explicitly enable authentication with an auto-generated `UUID` v4 token.
478    ///
479    /// **Authentication is already enabled by default** (see [`Self::auth_disabled`]),
480    /// so this call is redundant in normal use and is kept for explicitness and
481    /// backward compatibility. The token is never logged; it is written only to
482    /// the per-user discovery directory (`<root>/<pid>/token`; the root is `%TEMP%\victauri` on
483    /// Windows, and on Unix `$XDG_RUNTIME_DIR/victauri`, `<temp>/victauri-<uid>` or
484    /// `~/.local/state/victauri`)
485    /// for client auto-discovery.
486    #[must_use]
487    pub fn auth_enabled(mut self) -> Self {
488        self.auth_explicitly_enabled = true;
489        self
490    }
491
492    /// Generate a random `UUID` v4 auth token (alias for [`auth_enabled`](Self::auth_enabled)).
493    #[must_use]
494    pub fn generate_auth_token(mut self) -> Self {
495        self.auth_explicitly_enabled = true;
496        self
497    }
498
499    /// Disable authentication entirely.
500    ///
501    /// By default, Victauri auto-generates a Bearer token and writes it to the
502    /// discovery directory. Call this method to opt out of authentication for
503    /// single-user local development where convenience outweighs the risk.
504    ///
505    /// **Warning:** Without auth, any process on localhost can invoke all MCP
506    /// tools including `eval_js` and `invoke_command`. Only disable auth on
507    /// machines where you trust every running process.
508    #[must_use]
509    pub fn auth_disabled(mut self) -> Self {
510        self.auth_explicitly_disabled = true;
511        self
512    }
513
514    /// Disable specific MCP tools by name (e.g., `["eval_js", "screenshot"]`).
515    ///
516    /// Accepts a bare tool name (disables every action of a compound tool) or a single
517    /// action as `tool.action` (e.g. `"window.manage"`, `"inspect.get_styles"`). A name that
518    /// matches no tool or action disables nothing, so it is logged as a warning.
519    #[must_use]
520    pub fn disable_tools(mut self, tools: &[&str]) -> Self {
521        for unknown in mcp::authz::unknown_names(tools.iter().copied()) {
522            tracing::warn!(
523                name = unknown,
524                "disable_tools: {unknown:?} is not a Victauri tool, `tool.action`, or capability \
525                 name, so it disables nothing (check the spelling against the tool list)"
526            );
527        }
528        self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
529        self
530    }
531
532    /// Only allow these Tauri commands to be invoked via MCP (positive allowlist).
533    #[must_use]
534    pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
535        self.command_allowlist = Some(
536            commands
537                .iter()
538                .map(std::string::ToString::to_string)
539                .collect(),
540        );
541        self
542    }
543
544    /// Block specific Tauri commands from being invoked via MCP.
545    #[must_use]
546    pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
547        self.command_blocklist = commands
548            .iter()
549            .map(std::string::ToString::to_string)
550            .collect();
551        self
552    }
553
554    /// Block `storage.set` from writing these `localStorage`/`sessionStorage` keys.
555    /// Use it to protect keys your app trusts for auth/role/tier/feature decisions
556    /// so an agent cannot escalate privilege by poisoning them (audit #33). Default
557    /// is empty (no keys blocked), preserving existing behaviour.
558    #[must_use]
559    pub fn storage_key_blocklist(mut self, keys: &[&str]) -> Self {
560        self.storage_key_blocklist = keys.iter().map(std::string::ToString::to_string).collect();
561        self
562    }
563
564    /// Add a regex pattern for output redaction (e.g., `r"SECRET_\w+"`).
565    #[must_use]
566    pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
567        self.redaction_patterns.push(pattern.into());
568        self
569    }
570
571    /// Enable output redaction with built-in patterns (API keys, emails, tokens).
572    #[must_use]
573    pub fn enable_redaction(mut self) -> Self {
574        self.redaction_enabled = true;
575        self
576    }
577
578    /// Enable strict privacy mode (equivalent to [`PrivacyProfile::Observe`]).
579    ///
580    /// Disables all mutation tools (`eval_js`, screenshot, interactions, input,
581    /// storage writes, navigation, CSS injection) and enables output redaction.
582    ///
583    /// Prefer [`privacy_profile()`](Self::privacy_profile) for finer control.
584    #[must_use]
585    pub fn strict_privacy_mode(mut self) -> Self {
586        self.strict_privacy = true;
587        self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
588        self
589    }
590
591    /// Set the privacy profile tier.
592    ///
593    /// - [`Observe`](privacy::PrivacyProfile::Observe) — read-only (snapshots, logs, registry)
594    /// - [`Test`](privacy::PrivacyProfile::Test) — observe + interactions + input + storage writes + recording
595    /// - [`FullControl`](privacy::PrivacyProfile::FullControl) — everything (default)
596    ///
597    /// Profiles automatically enable redaction for `Observe` and `Test`.
598    /// Use [`disable_tools()`](Self::disable_tools) to apply overrides on top of a profile.
599    #[must_use]
600    pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
601        self.privacy_profile = Some(profile);
602        if matches!(
603            profile,
604            privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
605        ) {
606            self.redaction_enabled = true;
607        }
608        self
609    }
610
611    /// Set the maximum console log entries kept in the JS bridge (default: 1000).
612    #[must_use]
613    pub fn console_log_capacity(mut self, capacity: usize) -> Self {
614        self.bridge_capacities.console_logs = capacity;
615        self
616    }
617
618    /// Set the maximum network log entries kept in the JS bridge (default: 1000).
619    #[must_use]
620    pub fn network_log_capacity(mut self, capacity: usize) -> Self {
621        self.bridge_capacities.network_log = capacity;
622        self
623    }
624
625    /// Set the maximum navigation log entries kept in the JS bridge (default: 200).
626    #[must_use]
627    pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
628        self.bridge_capacities.navigation_log = capacity;
629        self
630    }
631
632    /// Pre-register command schemas so `get_registry`, `resolve_command`, and
633    /// `detect_ghost_commands` return real results from the moment the server starts.
634    ///
635    /// Pass the `__schema()` functions generated by `#[inspectable]`.
636    ///
637    /// ```rust,ignore
638    /// VictauriBuilder::new()
639    ///     .commands(&[
640    ///         greet__schema(),
641    ///         increment__schema(),
642    ///         add_todo__schema(),
643    ///     ])
644    ///     .build()
645    /// ```
646    #[must_use]
647    pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
648        self.commands = schemas.to_vec();
649        self
650    }
651
652    /// Register command names without full schemas.
653    ///
654    /// Use this when your commands are decorated with `#[tauri::command]` but not
655    /// `#[inspectable]`. Ghost detection will recognize these commands as registered,
656    /// eliminating false positives.
657    ///
658    /// ```rust,ignore
659    /// VictauriBuilder::new()
660    ///     .register_command_names(&[
661    ///         "get_settings",
662    ///         "save_settings",
663    ///         "run_analysis",
664    ///     ])
665    ///     .build()
666    /// ```
667    #[must_use]
668    pub fn register_command_names(mut self, names: &[&str]) -> Self {
669        self.commands
670            .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
671        self
672    }
673
674    /// Auto-discover all `#[inspectable]` commands in the binary.
675    ///
676    /// Uses `inventory` to collect every command marked with `#[inspectable]`
677    /// at link time — no manual listing required. This replaces both
678    /// `.commands(&[...])` and `register_commands!()`.
679    ///
680    /// ```rust,ignore
681    /// tauri::Builder::default()
682    ///     .plugin(
683    ///         VictauriBuilder::new()
684    ///             .auto_discover()
685    ///             .build()
686    ///             .unwrap(),
687    ///     )
688    /// ```
689    #[must_use]
690    pub fn auto_discover(mut self) -> Self {
691        self.commands
692            .extend(victauri_core::auto_discovered_commands());
693        self
694    }
695
696    /// Register custom Tauri event names to capture in the event bus.
697    ///
698    /// Window lifecycle events (focus, blur, close, resize, move, theme change) are
699    /// captured automatically. Use this for app-specific events emitted via `app.emit()`.
700    ///
701    /// ```rust,ignore
702    /// VictauriBuilder::new()
703    ///     .listen_events(&["notification-added", "settings-changed", "sync-complete"])
704    ///     .build()
705    /// ```
706    #[must_use]
707    pub fn listen_events(mut self, events: &[&str]) -> Self {
708        self.listen_events = events
709            .iter()
710            .map(std::string::ToString::to_string)
711            .collect();
712        self
713    }
714
715    /// Allow `file:` URLs in the `navigate` tool's `go_to` action.
716    ///
717    /// By default, only `http` and `https` schemes are permitted. Calling this
718    /// method opts in to `file:` navigation, which grants the MCP client access
719    /// to local filesystem paths via the webview.
720    ///
721    /// **Warning:** Enabling this in untrusted environments exposes local files
722    /// to any process that can reach the MCP server.
723    #[must_use]
724    pub fn allow_file_navigation(mut self) -> Self {
725        self.allow_file_navigation = true;
726        self
727    }
728
729    /// Add extra directories for `query_db` and `introspect db_health` to search
730    /// for `SQLite` databases, beyond the OS app directories.
731    ///
732    /// Many apps store their database outside the Tauri app-data directory (in a
733    /// project/working directory, a user-chosen location, or a configured path).
734    /// By default Victauri only searches the OS app directories, so those
735    /// databases are unreachable. Registering their containing directory here
736    /// makes them discoverable by relative name and permits absolute `query_db`
737    /// paths that resolve within these roots.
738    ///
739    /// Access remains read-only and path-traversal-guarded. Paths are added
740    /// as-is; relative paths are resolved against the process working directory.
741    #[must_use]
742    pub fn db_search_paths<I, P>(mut self, paths: I) -> Self
743    where
744        I: IntoIterator<Item = P>,
745        P: Into<std::path::PathBuf>,
746    {
747        self.db_search_paths
748            .extend(paths.into_iter().map(Into::into));
749        self
750    }
751
752    /// Register an application state probe surfaced through the `app_state` tool.
753    ///
754    /// A probe is a named closure returning a JSON snapshot of domain-specific
755    /// backend state. It gives an agent first-class, discoverable access to state
756    /// that would otherwise only be reachable by `query_db` + grepping logs —
757    /// e.g. a scoring pipeline's version and queue depth, a cache's hit rate, or
758    /// a scheduler's next-run time. The closure runs in the Rust process with no
759    /// IPC round-trip, so it can read backend state the frontend never sees.
760    ///
761    /// The idiomatic pattern is to build your shared state as an `Arc` first, then
762    /// clone it into both Tauri's `.manage()` and the probe:
763    ///
764    /// ```no_run
765    /// # use std::sync::Arc;
766    /// # use serde_json::json;
767    /// # struct Scoring; impl Scoring { fn version(&self) -> u32 { 5 } fn stale(&self) -> u64 { 0 } }
768    /// let scoring = Arc::new(Scoring);
769    /// let probe_state = Arc::clone(&scoring);
770    /// let builder = victauri_plugin::VictauriBuilder::new().probe("scoring", move || {
771    ///     json!({ "pipeline_version": probe_state.version(), "stale_items": probe_state.stale() })
772    /// });
773    /// # let _ = builder;
774    /// ```
775    ///
776    /// # Threading
777    ///
778    /// The closure runs on tokio's **blocking pool** (under a deadline, with panics isolated),
779    /// never on the Tauri main thread. It must therefore not capture, clone or drop Tauri
780    /// handles (`AppHandle`, `Window`, `Webview`, `WebviewWindow`, …) nor call
781    /// `get_webview_window` / `webview_windows`: on Linux every Tauri handle carries a
782    /// non-atomic `Rc` owned by the main thread (tao's window set), and touching it from another
783    /// thread corrupts the host's heap. Read your own `Send + Sync` state (an `Arc`, a lock, an
784    /// atomic) instead, as above.
785    #[must_use]
786    pub fn probe<F>(mut self, name: impl Into<String>, probe: F) -> Self
787    where
788        F: Fn() -> serde_json::Value + Send + Sync + 'static,
789    {
790        self.probes.push((name.into(), std::sync::Arc::new(probe)));
791        self
792    }
793
794    /// Register a callback invoked once the MCP server is listening.
795    /// The callback receives the port number.
796    ///
797    /// # Threading
798    ///
799    /// The callback runs on a **tokio worker thread** (Tauri's async runtime), not on the main
800    /// thread, and blocks that worker while it runs — keep it short. Like a
801    /// [`probe`](Self::probe), it must not capture, clone or drop Tauri handles nor call
802    /// `get_webview_window` / `webview_windows` (on Linux that races the main thread's
803    /// non-atomic `Rc` and corrupts the heap) — moving a handle into the callback means it is
804    /// dropped on the worker. To act on the app, signal your own main-thread code instead (a
805    /// channel, an atomic, or state it reads).
806    #[must_use]
807    pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
808        self.on_ready = Some(Box::new(f));
809        self
810    }
811
812    fn resolve_port(&self) -> u16 {
813        self.port
814            .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
815            .unwrap_or(DEFAULT_PORT)
816    }
817
818    fn resolve_auth_token(&self) -> Option<String> {
819        if self.auth_explicitly_disabled {
820            return None;
821        }
822        // An EMPTY/whitespace configured token or env var is treated as "not configured" and
823        // falls through to a freshly generated token — it must NOT disable auth. The contract is
824        // "auth is on by default unless `auth_disabled()`"; a botched `VICTAURI_AUTH_TOKEN=""`
825        // (or `.auth_token("")`) previously collapsed to no-auth downstream, a silent fail-open
826        // that bypassed that contract without the caller ever invoking `auth_disabled()`.
827        if let Some(ref token) = self.auth_token
828            && !token.trim().is_empty()
829        {
830            return Some(token.clone());
831        }
832        if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN")
833            && !token.trim().is_empty()
834        {
835            return Some(token);
836        }
837        Some(auth::generate_token())
838    }
839
840    fn resolve_eval_timeout(&self) -> std::time::Duration {
841        std::env::var("VICTAURI_EVAL_TIMEOUT")
842            .ok()
843            .and_then(|s| s.parse::<u64>().ok())
844            .map_or(self.eval_timeout, std::time::Duration::from_secs)
845    }
846
847    fn build_privacy_config(&self) -> privacy::PrivacyConfig {
848        let profile = self
849            .privacy_profile
850            .unwrap_or(privacy::PrivacyProfile::FullControl);
851
852        let redaction_enabled = self.redaction_enabled
853            || self.strict_privacy
854            || matches!(
855                profile,
856                privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
857            );
858
859        privacy::PrivacyConfig {
860            profile,
861            command_allowlist: self
862                .command_allowlist
863                .as_ref()
864                .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
865            command_blocklist: self.command_blocklist.iter().cloned().collect(),
866            disabled_tools: self.disabled_tools.iter().cloned().collect(),
867            storage_key_blocklist: self.storage_key_blocklist.iter().cloned().collect(),
868            redactor: redaction::Redactor::new(&self.redaction_patterns),
869            redaction_enabled,
870        }
871    }
872
873    fn validate(&self) -> Result<(), BuilderError> {
874        let port = self.resolve_port();
875        if port == 0 {
876            return Err(BuilderError::InvalidPort {
877                port,
878                reason: "port 0 is reserved".to_string(),
879            });
880        }
881
882        if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
883            return Err(BuilderError::InvalidEventCapacity {
884                capacity: self.event_capacity,
885                reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
886            });
887        }
888
889        if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
890            return Err(BuilderError::InvalidRecorderCapacity {
891                capacity: self.recorder_capacity,
892                reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
893            });
894        }
895
896        let timeout = self.resolve_eval_timeout();
897        if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
898            return Err(BuilderError::InvalidEvalTimeout {
899                timeout_secs: timeout.as_secs(),
900                reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
901            });
902        }
903
904        Ok(())
905    }
906
907    /// Consume the builder and produce a Tauri plugin.
908    ///
909    /// In release builds this always succeeds. In debug builds the builder configuration is
910    /// validated first.
911    ///
912    /// # Errors
913    ///
914    /// Returns [`BuilderError`] if the port, event capacity, recorder capacity, or eval
915    /// timeout are outside their valid ranges (debug builds only).
916    pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
917        #[cfg(not(debug_assertions))]
918        {
919            Ok(Builder::new("victauri").build())
920        }
921
922        #[cfg(debug_assertions)]
923        {
924            // Hard kill-switch: lets a team force the no-op plugin even in a debug
925            // build (e.g. a release profile compiled with `debug-assertions = true`,
926            // or a shared environment where the introspection server must never bind).
927            if env_truthy("VICTAURI_DISABLE") {
928                tracing::info!("Victauri disabled via VICTAURI_DISABLE — returning no-op plugin");
929                return Ok(Builder::new("victauri").build());
930            }
931
932            self.validate()?;
933
934            let port = self.resolve_port();
935            let event_capacity = self.event_capacity;
936            let recorder_capacity = self.recorder_capacity;
937            let eval_timeout = self.resolve_eval_timeout();
938            let auth_token = self.resolve_auth_token();
939            let privacy_config = self.build_privacy_config();
940            let allow_file_navigation = self.allow_file_navigation;
941            let db_search_paths = self.db_search_paths;
942            let on_ready = self.on_ready;
943            let commands = self.commands;
944            let listen_events = self.listen_events;
945            let probes = self.probes;
946            let js_init = js_bridge::init_script(&self.bridge_capacities);
947
948            Ok(Builder::new("victauri")
949                .setup(move |app, _api| {
950                    // Record the main (UI) thread so the bridge can run main-thread callers
951                    // inline instead of deadlocking on its lock, and give the bridge a handle
952                    // owned by that thread (see `bridge::on_main`). Tauri runs setup there —
953                    // unless the app registered this plugin at runtime from a background thread
954                    // (`AppHandle::plugin`); then the adoption is posted to the main thread.
955                    match bridge::adopt_main_thread(app) {
956                        Ok(bridge::MainThreadAdoption::Inline) => {}
957                        Ok(bridge::MainThreadAdoption::Posted) => tracing::warn!(
958                            "Victauri: plugin setup ran off the main thread (registered at \
959                             runtime from a background thread?); the main-thread state is being \
960                             installed on the main thread — webview/window tools called before \
961                             it is will fail with \"not installed\""
962                        ),
963                        Err(e) => tracing::error!(
964                            "Victauri: could not reach the main thread to install its state \
965                             ({e}); webview and window tools will fail with \"not installed\""
966                        ),
967                    }
968                    let startup_timeline = introspection::StartupTimeline::new();
969                    let event_log = EventLog::new(event_capacity);
970                    startup_timeline.mark("event_log_created");
971                    let registry = CommandRegistry::new();
972                    startup_timeline.mark("registry_created");
973                    let (shutdown_tx, shutdown_rx) = watch::channel(false);
974
975                    // Resolve configured db search paths against multiple anchors so a
976                    // CWD-relative path (e.g. 4DA's "../data", relative to src-tauri/) is
977                    // found regardless of the launch CWD — the binary usually runs from
978                    // target/debug/, so CWD-only resolution silently misses the database.
979                    let db_search_paths = resolve_db_search_paths(&db_search_paths);
980
981                    let state = Arc::new(VictauriState {
982                        event_log,
983                        registry,
984                        port: AtomicU16::new(port),
985                        pending_evals: Arc::new(Mutex::new(HashMap::new())),
986                        recorder: EventRecorder::new(recorder_capacity),
987                        privacy: privacy_config,
988                        eval_timeout,
989                        shutdown_tx,
990                        started_at: std::time::Instant::now(),
991                        tool_invocations: AtomicU64::new(0),
992                        allow_file_navigation,
993                        command_timings: introspection::CommandTimings::new(),
994                        fault_registry: introspection::FaultRegistry::new(),
995                        contract_store: introspection::ContractStore::new(),
996                        startup_timeline,
997                        event_bus: introspection::EventBusMonitor::default(),
998                        task_tracker: introspection::TaskTracker::new(),
999                        bridge_ready: AtomicBool::new(false),
1000                        bridge_notify: tokio::sync::Notify::new(),
1001                        screencast: Arc::new(screencast::Screencast::default()),
1002                        db_search_paths,
1003                        probes: introspection::AppStateProbes::default(),
1004                        drain_watermarks: introspection::DrainWatermarks::default(),
1005                        page_loads: introspection::PageLoads::default(),
1006                    });
1007                    state.startup_timeline.mark("state_created");
1008
1009                    for (name, probe) in probes {
1010                        state.probes.register(name, probe);
1011                    }
1012
1013                    app.manage(state.clone());
1014
1015                    for cmd in commands {
1016                        state.registry.register(cmd);
1017                    }
1018                    state.startup_timeline.mark("commands_registered");
1019
1020                    // Register listeners for custom event names specified via builder.
1021                    for event_name in &listen_events {
1022                        let bus = state.event_bus.clone();
1023                        let name = event_name.clone();
1024                        app.listen_any(event_name.clone(), move |event| {
1025                            let payload =
1026                                serde_json::from_str::<serde_json::Value>(event.payload())
1027                                    .map_or_else(
1028                                        |_| event.payload().to_string(),
1029                                        |v| v.to_string(),
1030                                    );
1031                            bus.push(introspection::CapturedTauriEvent {
1032                                name: name.clone(),
1033                                payload,
1034                                timestamp: chrono::Utc::now()
1035                                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1036                            });
1037                        });
1038                    }
1039                    state
1040                        .startup_timeline
1041                        .mark("event_bus_listeners_registered");
1042
1043                    if auth_token.is_some() {
1044                        // Nothing about the token itself: even a prefix and length narrow a
1045                        // guess, and logs travel further than the owner-only discovery file.
1046                        tracing::info!(
1047                            "Victauri MCP server auth enabled — clients read the token from \
1048                             the discovery directory"
1049                        );
1050                    } else {
1051                        tracing::warn!(
1052                            "Victauri MCP server running WITHOUT auth — any localhost process can \
1053                             access all tools. Use VictauriBuilder::auth_enabled() or set \
1054                             VICTAURI_AUTH_TOKEN for shared/CI environments."
1055                        );
1056                    }
1057
1058                    state.startup_timeline.mark("server_spawning");
1059                    let app_handle = app.clone();
1060                    let ready_state = state.clone();
1061                    let server_finished = state.task_tracker.track_guarded("mcp_server");
1062                    let (bound_tx, bound_rx) = tokio::sync::oneshot::channel();
1063                    tauri::async_runtime::spawn(async move {
1064                        let _finished = server_finished;
1065                        match mcp::start_server_reporting_port(
1066                            app_handle,
1067                            state,
1068                            port,
1069                            auth_token,
1070                            shutdown_rx,
1071                            Some(bound_tx),
1072                        )
1073                        .await
1074                        {
1075                            Ok(()) => {
1076                                tracing::info!("Victauri MCP server stopped");
1077                            }
1078                            Err(e) => {
1079                                tracing::error!("Victauri MCP server failed: {e}");
1080                            }
1081                        }
1082                    });
1083
1084                    // The banner and `on_ready` report the port the server actually BOUND. They
1085                    // used the preferred port, and probing it by connecting could reach another
1086                    // process squatting on it — or report it after the 5s probe gave up even
1087                    // though nothing of ours was listening.
1088                    let ready_finished = ready_state.task_tracker.track_guarded("on_ready_probe");
1089                    tauri::async_runtime::spawn(async move {
1090                        let _finished = ready_finished;
1091                        match tokio::time::timeout(std::time::Duration::from_secs(5), bound_rx)
1092                            .await
1093                        {
1094                            Ok(Ok(Ok(actual_port))) => {
1095                                emit_security_banner(actual_port);
1096                                if let Some(cb) = on_ready {
1097                                    cb(actual_port);
1098                                }
1099                            }
1100                            Ok(Ok(Err(e))) => tracing::warn!(
1101                                "Victauri: server did not start ({e}); on_ready not called"
1102                            ),
1103                            Ok(Err(_)) | Err(_) => tracing::warn!(
1104                                "Victauri: server did not report a bound port within 5s; \
1105                                 on_ready not called"
1106                            ),
1107                        }
1108                    });
1109                    Ok(())
1110                })
1111                .on_event(|app, event| {
1112                    let Some(state) = app.try_state::<Arc<VictauriState>>() else {
1113                        return;
1114                    };
1115                    match event {
1116                        RunEvent::Exit => {
1117                            // `send_replace`: the value must flip even with no receiver left
1118                            // (`send` then leaves it unchanged), since an eval started after
1119                            // this reads it directly.
1120                            state.shutdown_tx.send_replace(true);
1121                            tracing::info!("Victauri shutdown signal sent");
1122                        }
1123                        RunEvent::ExitRequested { .. } => {
1124                            state.event_bus.push(introspection::CapturedTauriEvent {
1125                                name: "tauri://exit-requested".to_string(),
1126                                payload: String::new(),
1127                                timestamp: chrono::Utc::now()
1128                                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1129                            });
1130                        }
1131                        RunEvent::WindowEvent {
1132                            label,
1133                            event: win_event,
1134                            ..
1135                        } => {
1136                            let (name, payload) = format_window_event(label, win_event);
1137                            state.event_bus.push(introspection::CapturedTauriEvent {
1138                                name,
1139                                payload,
1140                                timestamp: chrono::Utc::now()
1141                                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1142                            });
1143                        }
1144                        _ => {}
1145                    }
1146                })
1147                .js_init_script(js_init)
1148                .invoke_handler(tauri::generate_handler![
1149                    tools::victauri_eval_js,
1150                    tools::victauri_eval_callback,
1151                    tools::victauri_get_window_state,
1152                    tools::victauri_list_windows,
1153                    tools::victauri_get_ipc_log,
1154                    tools::victauri_get_registry,
1155                    tools::victauri_get_memory_stats,
1156                    tools::victauri_dom_snapshot,
1157                    tools::victauri_verify_state,
1158                    tools::victauri_detect_ghost_commands,
1159                    tools::victauri_check_ipc_integrity,
1160                ])
1161                .build())
1162        }
1163    }
1164}
1165
1166#[cfg(debug_assertions)]
1167fn format_window_event(label: &str, event: &tauri::WindowEvent) -> (String, String) {
1168    match event {
1169        tauri::WindowEvent::Resized(size) => (
1170            format!("window:{label}:resized"),
1171            serde_json::json!({"width": size.width, "height": size.height}).to_string(),
1172        ),
1173        tauri::WindowEvent::Moved(pos) => (
1174            format!("window:{label}:moved"),
1175            serde_json::json!({"x": pos.x, "y": pos.y}).to_string(),
1176        ),
1177        tauri::WindowEvent::CloseRequested { .. } => {
1178            (format!("window:{label}:close-requested"), String::new())
1179        }
1180        tauri::WindowEvent::Destroyed => (format!("window:{label}:destroyed"), String::new()),
1181        tauri::WindowEvent::Focused(focused) => (
1182            format!("window:{label}:focused"),
1183            serde_json::json!({"focused": focused}).to_string(),
1184        ),
1185        tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => (
1186            format!("window:{label}:scale-factor-changed"),
1187            serde_json::json!({"scale_factor": scale_factor}).to_string(),
1188        ),
1189        tauri::WindowEvent::ThemeChanged(theme) => (
1190            format!("window:{label}:theme-changed"),
1191            serde_json::json!({"theme": format!("{theme:?}")}).to_string(),
1192        ),
1193        tauri::WindowEvent::DragDrop(drag_event) => (
1194            format!("window:{label}:drag-drop"),
1195            format!("{drag_event:?}"),
1196        ),
1197        _ => (format!("window:{label}:other"), format!("{event:?}")),
1198    }
1199}
1200
1201/// Returns `true` if the named environment variable is set to a truthy value
1202/// (`1`, `true`, `yes`, or `on`, case-insensitive, surrounding whitespace ignored).
1203/// Unset, empty, or any other value is `false`.
1204#[cfg(debug_assertions)]
1205fn env_truthy(name: &str) -> bool {
1206    std::env::var(name).is_ok_and(|v| {
1207        matches!(
1208            v.trim().to_ascii_lowercase().as_str(),
1209            "1" | "true" | "yes" | "on"
1210        )
1211    })
1212}
1213
1214/// Emits an unmissable startup banner whenever the introspection server activates.
1215///
1216/// Victauri is a debug-only tool that exposes JS eval, IPC, the filesystem, and
1217/// `SQLite` over localhost. Logging this loudly on every start guarantees it can
1218/// never run *silently* — most importantly catching the dangerous case of a
1219/// release build accidentally compiled with `debug-assertions = true`, where the
1220/// `#[cfg(debug_assertions)]` gate would otherwise let the full server bind with
1221/// no visible signal.
1222#[cfg(debug_assertions)]
1223fn emit_security_banner(port: u16) {
1224    tracing::warn!(
1225        "┌─ VICTAURI INTROSPECTION SERVER ACTIVE ─────────────────────────────\n\
1226         │ Listening on http://127.0.0.1:{port} — exposes JS eval, IPC, the\n\
1227         │ filesystem, and SQLite to local clients. DEBUG-ONLY developer tool;\n\
1228         │ it must never reach end users.\n\
1229         │ Seeing this in a shipped/release build means your release profile has\n\
1230         │ `debug-assertions = true`. Turn that off, or hard-disable Victauri\n\
1231         │ with the VICTAURI_DISABLE=1 environment variable.\n\
1232         └────────────────────────────────────────────────────────────────────"
1233    );
1234}
1235
1236/// Initialize the Victauri plugin with default settings (port 7373 or `VICTAURI_PORT` env var).
1237///
1238/// In debug builds: starts the embedded MCP server with **authentication enabled**
1239/// (auto-generated token written to the per-user discovery directory (`<root>/<pid>/token`; the root is `%TEMP%\victauri` on
1240/// Windows, and on Unix `$XDG_RUNTIME_DIR/victauri`, `<temp>/victauri-<uid>` or
1241/// `~/.local/state/victauri`)), injects the JS
1242/// bridge, and registers all Tauri command handlers.
1243///
1244/// In release builds: returns a no-op plugin. The MCP server, JS bridge, and
1245/// all introspection tools are completely stripped — zero overhead, zero attack surface.
1246///
1247/// Setting the `VICTAURI_DISABLE=1` environment variable forces the no-op plugin
1248/// even in a debug build — a hard kill-switch for shared environments or a release
1249/// profile compiled with `debug-assertions = true`. When the server does activate
1250/// Resolve configured `db_search_paths` against multiple stable anchors so a
1251/// relative path is found regardless of the launch CWD.
1252///
1253/// Real apps register CWD-relative paths — e.g. 4DA's `../data` is relative to
1254/// `src-tauri/` — but the binary usually runs from `target/debug/` or a bundle,
1255/// so resolving only against the current directory silently misses the database
1256/// (the single most valuable backend surface). We resolve each relative path
1257/// against the CWD **and every ancestor of the executable**, keeping each
1258/// candidate that exists as a directory. Absolute paths pass through. The result
1259/// is deduped canonical directories.
1260fn resolve_db_search_paths(configured: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
1261    resolve_db_search_paths_against(configured, &db_search_anchors())
1262}
1263
1264/// Collect stable anchor directories for resolving relative db search paths: the
1265/// CWD plus every ancestor of the executable.
1266fn db_search_anchors() -> Vec<std::path::PathBuf> {
1267    use std::path::PathBuf;
1268    let mut anchors: Vec<PathBuf> = Vec::new();
1269    if let Ok(cwd) = std::env::current_dir() {
1270        anchors.push(cwd);
1271    }
1272    if let Ok(exe) = std::env::current_exe() {
1273        let mut dir = exe.parent().map(PathBuf::from);
1274        // Walk up the executable's ancestors (target/debug -> target -> src-tauri
1275        // -> project root -> ...), so a path relative to any of them resolves.
1276        for _ in 0..8 {
1277            match dir {
1278                Some(d) => {
1279                    anchors.push(d.clone());
1280                    dir = d.parent().map(PathBuf::from);
1281                }
1282                None => break,
1283            }
1284        }
1285    }
1286    anchors
1287}
1288
1289/// Pure core (testable): resolve each configured path against the given anchors,
1290/// keeping every candidate that exists as a directory. Absolute paths pass
1291/// through. Result is deduped canonical directories.
1292fn resolve_db_search_paths_against(
1293    configured: &[std::path::PathBuf],
1294    anchors: &[std::path::PathBuf],
1295) -> Vec<std::path::PathBuf> {
1296    let mut out: Vec<std::path::PathBuf> = Vec::new();
1297    for p in configured {
1298        if p.is_absolute() {
1299            out.push(p.canonicalize().unwrap_or_else(|_| p.clone()));
1300        } else {
1301            for a in anchors {
1302                if let Ok(c) = a.join(p).canonicalize()
1303                    && c.is_dir()
1304                {
1305                    out.push(c);
1306                }
1307            }
1308        }
1309    }
1310    out.sort();
1311    out.dedup();
1312    out
1313}
1314
1315/// it always logs a prominent startup banner, so it can never run silently.
1316///
1317/// For custom configuration, use `VictauriBuilder::new().port(8080).build()`.
1318///
1319/// # Panics
1320///
1321/// Panics if the default builder configuration is invalid (this is a bug).
1322#[must_use]
1323pub fn init<R: Runtime>() -> TauriPlugin<R> {
1324    VictauriBuilder::new()
1325        .build()
1326        .expect("default Victauri configuration is always valid")
1327}
1328
1329/// Initialize the Victauri plugin with auto-discovery of all `#[inspectable]` commands.
1330///
1331/// Equivalent to `VictauriBuilder::new().auto_discover().build()` — all commands
1332/// marked with `#[inspectable]` are registered automatically without manual listing.
1333///
1334/// # Panics
1335///
1336/// Panics if the default builder configuration is invalid (this is a bug).
1337#[must_use]
1338pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
1339    VictauriBuilder::new()
1340        .auto_discover()
1341        .build()
1342        .expect("default Victauri configuration is always valid")
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347    use super::*;
1348
1349    #[test]
1350    fn db_search_resolves_relative_path_via_a_non_cwd_anchor() {
1351        // Reproduces the live-4DA gap: the DB lives under one anchor (e.g. the
1352        // project dir) via a relative path, but the process CWD is elsewhere
1353        // (e.g. target/debug). Multi-anchor resolution must still find it.
1354        use std::path::PathBuf;
1355        let tmp = tempfile::tempdir().unwrap();
1356        let project = tmp.path().join("myapp");
1357        let data = project.join("data");
1358        std::fs::create_dir_all(&data).unwrap();
1359        std::fs::write(data.join("app.db"), b"x").unwrap();
1360        // Simulate the binary running from project/src-tauri/target/debug: the CWD
1361        // anchor (an unrelated dir) does NOT contain "../data", but the
1362        // "src-tauri" ancestor does.
1363        let wrong_cwd = tmp.path().join("elsewhere");
1364        std::fs::create_dir_all(&wrong_cwd).unwrap();
1365        let src_tauri = project.join("src-tauri");
1366        std::fs::create_dir_all(&src_tauri).unwrap();
1367
1368        let configured = vec![PathBuf::from("../data")]; // 4DA's exact config
1369        // CWD-only resolution (the old behavior) finds nothing:
1370        let cwd_only =
1371            resolve_db_search_paths_against(&configured, std::slice::from_ref(&wrong_cwd));
1372        assert!(
1373            cwd_only.is_empty(),
1374            "CWD-only should miss the db, got {cwd_only:?}"
1375        );
1376        // Multi-anchor resolution (CWD + the src-tauri ancestor) finds it:
1377        let multi = resolve_db_search_paths_against(&configured, &[wrong_cwd, src_tauri]);
1378        let want = data.canonicalize().unwrap();
1379        assert!(
1380            multi.contains(&want),
1381            "multi-anchor should resolve ../data to {want:?}, got {multi:?}"
1382        );
1383    }
1384
1385    #[test]
1386    fn db_search_passes_absolute_and_dedups() {
1387        use std::path::PathBuf;
1388        let tmp = tempfile::tempdir().unwrap();
1389        let data = tmp.path().join("data");
1390        std::fs::create_dir_all(&data).unwrap();
1391        let abs = data.canonicalize().unwrap();
1392        // Same dir reachable two ways must dedupe to one canonical entry.
1393        let out = resolve_db_search_paths_against(
1394            &[abs.clone(), PathBuf::from("data")],
1395            &[tmp.path().to_path_buf(), tmp.path().to_path_buf()],
1396        );
1397        assert_eq!(
1398            out.iter().filter(|p| **p == abs).count(),
1399            1,
1400            "must dedupe: {out:?}"
1401        );
1402    }
1403
1404    #[test]
1405    fn builder_default_values() {
1406        let builder = VictauriBuilder::new();
1407        assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
1408        assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
1409        assert!(builder.auth_token.is_none());
1410        assert!(!builder.auth_explicitly_enabled);
1411        assert!(!builder.auth_explicitly_disabled);
1412        let resolved = builder.resolve_auth_token();
1413        assert!(
1414            resolved.is_some(),
1415            "auth should be enabled by default (auto-generated token)"
1416        );
1417        assert_eq!(
1418            resolved.unwrap().len(),
1419            36,
1420            "auto-generated token should be UUID v4"
1421        );
1422        assert!(builder.disabled_tools.is_empty());
1423        assert!(builder.command_allowlist.is_none());
1424        assert!(builder.command_blocklist.is_empty());
1425        assert!(!builder.redaction_enabled);
1426        assert!(!builder.strict_privacy);
1427    }
1428
1429    #[test]
1430    fn builder_port_override() {
1431        let builder = VictauriBuilder::new().port(9090);
1432        assert_eq!(builder.resolve_port(), 9090);
1433    }
1434
1435    #[test]
1436    #[allow(unsafe_code)]
1437    fn builder_default_port() {
1438        let builder = VictauriBuilder::new();
1439        // SAFETY: test-only — no concurrent env reads in this test binary.
1440        unsafe { std::env::remove_var("VICTAURI_PORT") };
1441        assert_eq!(builder.resolve_port(), DEFAULT_PORT);
1442    }
1443
1444    #[test]
1445    fn builder_auth_token_explicit() {
1446        let builder = VictauriBuilder::new().auth_token("my-secret");
1447        assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
1448    }
1449
1450    // `env_truthy` is part of the debug-only kill-switch path, so this test can
1451    // only reference it in debug builds (otherwise `cargo test --release` fails to
1452    // compile — a config our CI doesn't normally run).
1453    #[cfg(debug_assertions)]
1454    #[test]
1455    #[allow(unsafe_code)]
1456    fn env_truthy_recognizes_kill_switch_values() {
1457        // Unique var name so this never races with other env-touching tests.
1458        let key = "VICTAURI_TEST_KILL_SWITCH";
1459        // SAFETY: test-only — this var is unique to this test, no concurrent readers.
1460        unsafe { std::env::remove_var(key) };
1461        assert!(!env_truthy(key), "unset should be false");
1462
1463        for v in ["1", "true", "TRUE", " yes ", "On"] {
1464            // SAFETY: test-only — unique var, no concurrent readers.
1465            unsafe { std::env::set_var(key, v) };
1466            assert!(env_truthy(key), "{v:?} should be truthy");
1467        }
1468        for v in ["0", "false", "", "nope"] {
1469            // SAFETY: test-only — unique var, no concurrent readers.
1470            unsafe { std::env::set_var(key, v) };
1471            assert!(!env_truthy(key), "{v:?} should be falsy");
1472        }
1473        // SAFETY: test-only — restore clean state.
1474        unsafe { std::env::remove_var(key) };
1475    }
1476
1477    #[test]
1478    fn builder_auth_enabled() {
1479        let builder = VictauriBuilder::new().auth_enabled();
1480        assert!(builder.auth_explicitly_enabled);
1481        let token = builder.resolve_auth_token().unwrap();
1482        assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
1483    }
1484
1485    #[test]
1486    fn builder_auth_generate_token() {
1487        let builder = VictauriBuilder::new().generate_auth_token();
1488        let token = builder.resolve_auth_token().unwrap();
1489        assert_eq!(token.len(), 36);
1490    }
1491
1492    #[test]
1493    fn builder_auth_disabled_suppresses_default_token() {
1494        let builder = VictauriBuilder::new().auth_disabled();
1495        assert!(
1496            builder.resolve_auth_token().is_none(),
1497            "auth_disabled must suppress the default auto-generated token (auth is ON by default)"
1498        );
1499    }
1500
1501    #[test]
1502    fn builder_auth_disabled_returns_none() {
1503        let builder = VictauriBuilder::new().auth_disabled();
1504        assert!(
1505            builder.resolve_auth_token().is_none(),
1506            "auth_disabled should suppress auto-generated token"
1507        );
1508    }
1509
1510    #[test]
1511    fn builder_auth_disabled_overrides_explicit_token() {
1512        let builder = VictauriBuilder::new()
1513            .auth_token("my-secret")
1514            .auth_disabled();
1515        assert!(
1516            builder.resolve_auth_token().is_none(),
1517            "auth_disabled should override explicit token"
1518        );
1519    }
1520
1521    #[test]
1522    fn builder_empty_explicit_token_does_not_disable_auth() {
1523        // Fail-open guard: an empty/whitespace token must NOT become "no auth"; it falls
1524        // through to a freshly generated token. Only `auth_disabled()` disables auth.
1525        for blank in ["", "   ", "\t\n"] {
1526            let resolved = VictauriBuilder::new()
1527                .auth_token(blank)
1528                .resolve_auth_token();
1529            assert!(
1530                resolved.as_deref().is_some_and(|t| !t.trim().is_empty()),
1531                "empty explicit token {blank:?} must resolve to a generated token, not no-auth"
1532            );
1533        }
1534    }
1535
1536    #[test]
1537    fn builder_capacities() {
1538        let builder = VictauriBuilder::new()
1539            .event_capacity(500)
1540            .recorder_capacity(2000);
1541        assert_eq!(builder.event_capacity, 500);
1542        assert_eq!(builder.recorder_capacity, 2000);
1543    }
1544
1545    #[test]
1546    fn builder_disable_tools() {
1547        let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
1548        assert_eq!(builder.disabled_tools.len(), 2);
1549        assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
1550    }
1551
1552    #[test]
1553    fn builder_command_allowlist() {
1554        let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
1555        assert!(builder.command_allowlist.is_some());
1556        assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
1557    }
1558
1559    #[test]
1560    fn builder_command_blocklist() {
1561        let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
1562        assert_eq!(builder.command_blocklist.len(), 1);
1563    }
1564
1565    #[test]
1566    fn builder_redaction() {
1567        let builder = VictauriBuilder::new()
1568            .add_redaction_pattern(r"SECRET_\w+")
1569            .enable_redaction();
1570        assert!(builder.redaction_enabled);
1571        assert_eq!(builder.redaction_patterns.len(), 1);
1572    }
1573
1574    #[test]
1575    fn builder_strict_privacy_config() {
1576        let builder = VictauriBuilder::new().strict_privacy_mode();
1577        let config = builder.build_privacy_config();
1578        assert!(config.redaction_enabled);
1579        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
1580        assert!(!config.is_tool_enabled("eval_js"));
1581        assert!(!config.is_tool_enabled("screenshot"));
1582        assert!(!config.is_tool_enabled("interact"));
1583        assert!(config.is_tool_enabled("dom_snapshot"));
1584    }
1585
1586    #[test]
1587    fn builder_normal_privacy_config() {
1588        let builder = VictauriBuilder::new()
1589            .command_blocklist(&["secret_cmd"])
1590            .disable_tools(&["eval_js"]);
1591        let config = builder.build_privacy_config();
1592        assert!(config.command_blocklist.contains("secret_cmd"));
1593        assert!(!config.is_tool_enabled("eval_js"));
1594        assert!(!config.redaction_enabled);
1595    }
1596
1597    #[test]
1598    fn builder_strict_with_extra_blocklist() {
1599        let builder = VictauriBuilder::new()
1600            .strict_privacy_mode()
1601            .command_blocklist(&["extra_dangerous"]);
1602        let config = builder.build_privacy_config();
1603        assert!(config.command_blocklist.contains("extra_dangerous"));
1604        assert!(!config.is_tool_enabled("eval_js"));
1605    }
1606
1607    #[test]
1608    fn builder_test_profile() {
1609        let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
1610        let config = builder.build_privacy_config();
1611        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
1612        assert!(config.redaction_enabled);
1613        assert!(config.is_tool_enabled("interact"));
1614        assert!(config.is_tool_enabled("fill"));
1615        assert!(config.is_tool_enabled("recording"));
1616        assert!(!config.is_tool_enabled("eval_js"));
1617        assert!(!config.is_tool_enabled("screenshot"));
1618        // The bare `navigate` tool surfaces (read actions allowed), but the
1619        // mutating `go_to` action stays FullControl-only.
1620        assert!(!config.is_tool_enabled("navigate.go_to"));
1621    }
1622
1623    #[test]
1624    fn builder_profile_with_extra_disables() {
1625        let builder = VictauriBuilder::new()
1626            .privacy_profile(crate::privacy::PrivacyProfile::Test)
1627            .disable_tools(&["interact"]);
1628        let config = builder.build_privacy_config();
1629        assert!(!config.is_tool_enabled("interact"));
1630        assert!(config.is_tool_enabled("fill"));
1631    }
1632
1633    #[test]
1634    fn builder_bridge_capacities() {
1635        let builder = VictauriBuilder::new()
1636            .console_log_capacity(5000)
1637            .network_log_capacity(2000)
1638            .navigation_log_capacity(500);
1639        assert_eq!(builder.bridge_capacities.console_logs, 5000);
1640        assert_eq!(builder.bridge_capacities.network_log, 2000);
1641        assert_eq!(builder.bridge_capacities.navigation_log, 500);
1642        assert_eq!(builder.bridge_capacities.mutation_log, 500);
1643        assert_eq!(builder.bridge_capacities.dialog_log, 100);
1644    }
1645
1646    #[test]
1647    fn builder_on_ready_sets_callback() {
1648        let builder = VictauriBuilder::new().on_ready(|_port| {});
1649        assert!(builder.on_ready.is_some());
1650    }
1651
1652    #[test]
1653    fn builder_file_navigation_disabled_by_default() {
1654        let builder = VictauriBuilder::new();
1655        assert!(
1656            !builder.allow_file_navigation,
1657            "file navigation should be disabled by default"
1658        );
1659    }
1660
1661    #[test]
1662    fn builder_allow_file_navigation() {
1663        let builder = VictauriBuilder::new().allow_file_navigation();
1664        assert!(builder.allow_file_navigation);
1665    }
1666
1667    #[test]
1668    fn builder_listen_events() {
1669        let builder =
1670            VictauriBuilder::new().listen_events(&["notification-added", "settings-changed"]);
1671        assert_eq!(builder.listen_events.len(), 2);
1672        assert!(
1673            builder
1674                .listen_events
1675                .contains(&"notification-added".to_string())
1676        );
1677        assert!(
1678            builder
1679                .listen_events
1680                .contains(&"settings-changed".to_string())
1681        );
1682    }
1683
1684    #[test]
1685    fn builder_listen_events_empty_by_default() {
1686        let builder = VictauriBuilder::new();
1687        assert!(builder.listen_events.is_empty());
1688    }
1689
1690    #[test]
1691    fn init_script_contains_custom_capacities() {
1692        let caps = js_bridge::BridgeCapacities {
1693            console_logs: 3000,
1694            mutation_log: 750,
1695            network_log: 5000,
1696            navigation_log: 400,
1697            dialog_log: 250,
1698            long_tasks: 200,
1699        };
1700        let script = js_bridge::init_script(&caps);
1701        assert!(script.contains("CAP_CONSOLE = 3000"));
1702        assert!(script.contains("CAP_MUTATION = 750"));
1703        assert!(script.contains("CAP_NETWORK = 5000"));
1704        assert!(script.contains("CAP_NAVIGATION = 400"));
1705        assert!(script.contains("CAP_DIALOG = 250"));
1706        assert!(script.contains("CAP_LONG_TASKS = 200"));
1707    }
1708
1709    #[test]
1710    fn init_script_default_contains_standard_capacities() {
1711        let caps = js_bridge::BridgeCapacities::default();
1712        let script = js_bridge::init_script(&caps);
1713        assert!(script.contains("CAP_CONSOLE = 1000"));
1714        assert!(script.contains("CAP_NETWORK = 1000"));
1715        assert!(script.contains("window.__VICTAURI__"));
1716    }
1717
1718    #[test]
1719    fn builder_validates_defaults() {
1720        let builder = VictauriBuilder::new();
1721        assert!(builder.validate().is_ok());
1722    }
1723
1724    #[test]
1725    fn builder_rejects_zero_port() {
1726        let builder = VictauriBuilder::new().port(0);
1727        let err = builder.validate().unwrap_err();
1728        assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
1729    }
1730
1731    #[test]
1732    fn builder_rejects_zero_event_capacity() {
1733        let builder = VictauriBuilder::new().event_capacity(0);
1734        let err = builder.validate().unwrap_err();
1735        assert!(matches!(
1736            err,
1737            BuilderError::InvalidEventCapacity { capacity: 0, .. }
1738        ));
1739    }
1740
1741    #[test]
1742    fn builder_rejects_excessive_event_capacity() {
1743        let builder = VictauriBuilder::new().event_capacity(2_000_000);
1744        assert!(builder.validate().is_err());
1745    }
1746
1747    #[test]
1748    fn builder_rejects_zero_recorder_capacity() {
1749        let builder = VictauriBuilder::new().recorder_capacity(0);
1750        assert!(builder.validate().is_err());
1751    }
1752
1753    #[test]
1754    fn builder_rejects_zero_eval_timeout() {
1755        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
1756        assert!(builder.validate().is_err());
1757    }
1758
1759    #[test]
1760    fn builder_rejects_excessive_eval_timeout() {
1761        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1762        assert!(builder.validate().is_err());
1763    }
1764
1765    #[test]
1766    fn builder_accepts_edge_values() {
1767        let builder = VictauriBuilder::new()
1768            .port(1)
1769            .event_capacity(1)
1770            .recorder_capacity(1)
1771            .eval_timeout(std::time::Duration::from_secs(1));
1772        assert!(builder.validate().is_ok());
1773
1774        let builder = VictauriBuilder::new()
1775            .port(65535)
1776            .event_capacity(MAX_EVENT_CAPACITY)
1777            .recorder_capacity(MAX_RECORDER_CAPACITY)
1778            .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1779        assert!(builder.validate().is_ok());
1780    }
1781}