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