1#![deny(missing_docs)]
2#![cfg_attr(not(debug_assertions), allow(unused_imports, dead_code))]
7pub mod bridge;
49#[cfg(feature = "sqlite")]
54#[doc(hidden)]
55pub mod database;
56pub mod error;
57#[doc(hidden)]
62pub mod js_bridge;
63pub mod mcp;
65mod memory;
66pub mod privacy;
68
69pub(crate) mod filmstrip;
71pub mod redaction;
75#[doc(hidden)]
80pub mod screencast;
81pub(crate) mod screenshot;
82mod tools;
83
84pub mod auth;
86pub 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#[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#[macro_export]
137macro_rules! register_commands {
138 ($app:expr, $($schema_call:expr),+ $(,)?) => {{
139 #[allow(unused_imports)]
142 use $crate::__private_tauri::Manager as _;
143 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#[doc(hidden)]
156pub use tauri as __private_tauri;
157
158#[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#[doc(hidden)]
185pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
186
187pub(crate) struct PendingSlot {
194 map: PendingCallbacks,
195 id: String,
196 on_app_runtime: bool,
199}
200
201impl PendingSlot {
202 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 pub(crate) fn reserved_on_app_runtime(mut self) -> Self {
221 self.on_app_runtime = true;
222 self
223 }
224
225 pub(crate) fn id(&self) -> &str {
227 &self.id
228 }
229
230 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#[derive(Clone, Debug, PartialEq, Eq)]
244enum EvalTarget {
245 Window(String),
247 DefaultWindow,
249}
250
251static 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
262pub(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
279pub(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 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 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#[non_exhaustive]
335pub struct VictauriState {
336 pub event_log: EventLog,
338 pub registry: CommandRegistry,
340 pub port: AtomicU16,
342 pub pending_evals: PendingCallbacks,
344 pub recorder: EventRecorder,
346 pub privacy: privacy::PrivacyConfig,
348 pub eval_timeout: std::time::Duration,
350 pub shutdown_tx: watch::Sender<bool>,
352 pub started_at: std::time::Instant,
354 pub tool_invocations: AtomicU64,
356 pub allow_file_navigation: bool,
360 pub command_timings: introspection::CommandTimings,
362 pub fault_registry: introspection::FaultRegistry,
364 pub contract_store: introspection::ContractStore,
366 pub startup_timeline: introspection::StartupTimeline,
368 pub event_bus: introspection::EventBusMonitor,
370 pub task_tracker: introspection::TaskTracker,
372 pub bridge_ready: AtomicBool,
374 pub bridge_notify: tokio::sync::Notify,
376 pub screencast: Arc<screencast::Screencast>,
378 pub db_search_paths: Vec<std::path::PathBuf>,
385 pub probes: introspection::AppStateProbes,
388 pub(crate) drain_watermarks: introspection::DrainWatermarks,
390 pub(crate) page_loads: introspection::PageLoads,
392 pub(crate) server_runtime: std::sync::OnceLock<tokio::runtime::Handle>,
396}
397
398impl VictauriState {
399 #[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
442pub 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 #[must_use]
512 pub fn new() -> Self {
513 Self::default()
514 }
515
516 #[must_use]
518 pub fn port(mut self, port: u16) -> Self {
519 self.port = Some(port);
520 self
521 }
522
523 #[must_use]
525 pub fn event_capacity(mut self, capacity: usize) -> Self {
526 self.event_capacity = capacity;
527 self
528 }
529
530 #[must_use]
532 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
533 self.recorder_capacity = capacity;
534 self
535 }
536
537 #[must_use]
539 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
540 self.eval_timeout = timeout;
541 self
542 }
543
544 #[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 #[must_use]
563 pub fn auth_enabled(mut self) -> Self {
564 self.auth_explicitly_enabled = true;
565 self
566 }
567
568 #[must_use]
570 pub fn generate_auth_token(mut self) -> Self {
571 self.auth_explicitly_enabled = true;
572 self
573 }
574
575 #[must_use]
585 pub fn auth_disabled(mut self) -> Self {
586 self.auth_explicitly_disabled = true;
587 self
588 }
589
590 #[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 #[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 #[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 #[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 #[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 #[must_use]
649 pub fn enable_redaction(mut self) -> Self {
650 self.redaction_enabled = true;
651 self
652 }
653
654 #[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 #[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 #[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 #[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 #[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 #[must_use]
723 pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
724 self.commands = schemas.to_vec();
725 self
726 }
727
728 #[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 #[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 #[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 #[must_use]
800 pub fn allow_file_navigation(mut self) -> Self {
801 self.allow_file_navigation = true;
802 self
803 }
804
805 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 #[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#[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 let parcel = Arc::new(std::sync::Mutex::new(Some((rt, server))));
1287 let for_thread = Arc::clone(&parcel);
1288 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 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 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#[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#[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
1395fn resolve_db_search_paths(configured: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
1406 resolve_db_search_paths_against(configured, &db_search_anchors())
1407}
1408
1409fn 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 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
1434fn 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#[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#[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 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 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")]; 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 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 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 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 #[cfg(debug_assertions)]
1615 #[test]
1616 #[allow(unsafe_code)]
1617 fn env_truthy_recognizes_kill_switch_values() {
1618 let key = "VICTAURI_TEST_KILL_SWITCH";
1620 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 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 unsafe { std::env::set_var(key, v) };
1632 assert!(!env_truthy(key), "{v:?} should be falsy");
1633 }
1634 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 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 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}