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