1#![deny(missing_docs)]
2#![cfg_attr(not(debug_assertions), allow(unused_imports, dead_code))]
7pub mod bridge;
46#[cfg(feature = "sqlite")]
48pub mod database;
49pub mod error;
50pub mod js_bridge;
52pub mod mcp;
54mod memory;
55pub mod privacy;
57
58pub mod filmstrip;
60pub mod redaction;
62pub mod screencast;
63pub(crate) mod screenshot;
64mod tools;
65
66pub mod auth;
68pub mod introspection;
70
71use std::collections::{HashMap, HashSet};
72use std::sync::Arc;
73use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64};
74use tauri::plugin::{Builder, TauriPlugin};
75use tauri::{Listener, Manager, RunEvent, Runtime};
76use tokio::sync::{Mutex, oneshot, watch};
77use victauri_core::{CommandRegistry, EventLog, EventRecorder};
78
79pub use error::BuilderError;
80pub use privacy::PrivacyProfile;
81
82pub use victauri_core::CommandInfo;
83pub use victauri_macros::inspectable;
84
85#[macro_export]
106macro_rules! register_commands {
107 ($app:expr, $($schema_call:expr),+ $(,)?) => {{
108 if let Some(state) = $app.try_state::<std::sync::Arc<$crate::VictauriState>>() {
112 $(
113 state.registry.register($schema_call);
114 )+
115 }
116 }};
117}
118
119const DEFAULT_PORT: u16 = 7373;
120const DEFAULT_EVENT_CAPACITY: usize = 10_000;
121const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
122const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
123const MAX_EVENT_CAPACITY: usize = 1_000_000;
124const MAX_RECORDER_CAPACITY: usize = 1_000_000;
125const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
126
127pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
130
131pub struct VictauriState {
133 pub event_log: EventLog,
135 pub registry: CommandRegistry,
137 pub port: AtomicU16,
139 pub pending_evals: PendingCallbacks,
141 pub recorder: EventRecorder,
143 pub privacy: privacy::PrivacyConfig,
145 pub eval_timeout: std::time::Duration,
147 pub shutdown_tx: watch::Sender<bool>,
149 pub started_at: std::time::Instant,
151 pub tool_invocations: AtomicU64,
153 pub allow_file_navigation: bool,
157 pub command_timings: introspection::CommandTimings,
159 pub fault_registry: introspection::FaultRegistry,
161 pub contract_store: introspection::ContractStore,
163 pub startup_timeline: introspection::StartupTimeline,
165 pub event_bus: introspection::EventBusMonitor,
167 pub task_tracker: introspection::TaskTracker,
169 pub bridge_ready: AtomicBool,
171 pub bridge_notify: tokio::sync::Notify,
173 pub screencast: Arc<screencast::Screencast>,
175 pub db_search_paths: Vec<std::path::PathBuf>,
182 pub probes: introspection::AppStateProbes,
185}
186
187pub struct VictauriBuilder {
199 port: Option<u16>,
200 event_capacity: usize,
201 recorder_capacity: usize,
202 eval_timeout: std::time::Duration,
203 auth_token: Option<String>,
204 auth_explicitly_enabled: bool,
205 auth_explicitly_disabled: bool,
206 disabled_tools: Vec<String>,
207 command_allowlist: Option<Vec<String>>,
208 command_blocklist: Vec<String>,
209 storage_key_blocklist: Vec<String>,
210 redaction_patterns: Vec<String>,
211 redaction_enabled: bool,
212 strict_privacy: bool,
213 privacy_profile: Option<privacy::PrivacyProfile>,
214 bridge_capacities: js_bridge::BridgeCapacities,
215 on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
216 commands: Vec<victauri_core::CommandInfo>,
217 allow_file_navigation: bool,
218 listen_events: Vec<String>,
219 db_search_paths: Vec<std::path::PathBuf>,
220 probes: Vec<(String, std::sync::Arc<introspection::ProbeFn>)>,
221}
222
223impl Default for VictauriBuilder {
224 fn default() -> Self {
225 Self {
226 port: None,
227 event_capacity: DEFAULT_EVENT_CAPACITY,
228 recorder_capacity: DEFAULT_RECORDER_CAPACITY,
229 eval_timeout: DEFAULT_EVAL_TIMEOUT,
230 auth_token: None,
231 auth_explicitly_enabled: false,
232 auth_explicitly_disabled: false,
233 disabled_tools: Vec::new(),
234 command_allowlist: None,
235 command_blocklist: Vec::new(),
236 storage_key_blocklist: Vec::new(),
237 redaction_patterns: Vec::new(),
238 redaction_enabled: false,
239 strict_privacy: false,
240 privacy_profile: None,
241 bridge_capacities: js_bridge::BridgeCapacities::default(),
242 on_ready: None,
243 commands: Vec::new(),
244 allow_file_navigation: false,
245 listen_events: Vec::new(),
246 db_search_paths: Vec::new(),
247 probes: Vec::new(),
248 }
249 }
250}
251
252impl VictauriBuilder {
253 #[must_use]
255 pub fn new() -> Self {
256 Self::default()
257 }
258
259 #[must_use]
261 pub fn port(mut self, port: u16) -> Self {
262 self.port = Some(port);
263 self
264 }
265
266 #[must_use]
268 pub fn event_capacity(mut self, capacity: usize) -> Self {
269 self.event_capacity = capacity;
270 self
271 }
272
273 #[must_use]
275 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
276 self.recorder_capacity = capacity;
277 self
278 }
279
280 #[must_use]
282 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
283 self.eval_timeout = timeout;
284 self
285 }
286
287 #[must_use]
291 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
292 self.auth_token = Some(token.into());
293 self
294 }
295
296 #[must_use]
304 pub fn auth_enabled(mut self) -> Self {
305 self.auth_explicitly_enabled = true;
306 self
307 }
308
309 #[must_use]
311 pub fn generate_auth_token(mut self) -> Self {
312 self.auth_explicitly_enabled = true;
313 self
314 }
315
316 #[must_use]
326 pub fn auth_disabled(mut self) -> Self {
327 self.auth_explicitly_disabled = true;
328 self
329 }
330
331 #[must_use]
333 pub fn disable_tools(mut self, tools: &[&str]) -> Self {
334 self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
335 self
336 }
337
338 #[must_use]
340 pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
341 self.command_allowlist = Some(
342 commands
343 .iter()
344 .map(std::string::ToString::to_string)
345 .collect(),
346 );
347 self
348 }
349
350 #[must_use]
352 pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
353 self.command_blocklist = commands
354 .iter()
355 .map(std::string::ToString::to_string)
356 .collect();
357 self
358 }
359
360 #[must_use]
365 pub fn storage_key_blocklist(mut self, keys: &[&str]) -> Self {
366 self.storage_key_blocklist = keys.iter().map(std::string::ToString::to_string).collect();
367 self
368 }
369
370 #[must_use]
372 pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
373 self.redaction_patterns.push(pattern.into());
374 self
375 }
376
377 #[must_use]
379 pub fn enable_redaction(mut self) -> Self {
380 self.redaction_enabled = true;
381 self
382 }
383
384 #[must_use]
391 pub fn strict_privacy_mode(mut self) -> Self {
392 self.strict_privacy = true;
393 self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
394 self
395 }
396
397 #[must_use]
406 pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
407 self.privacy_profile = Some(profile);
408 if matches!(
409 profile,
410 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
411 ) {
412 self.redaction_enabled = true;
413 }
414 self
415 }
416
417 #[must_use]
419 pub fn console_log_capacity(mut self, capacity: usize) -> Self {
420 self.bridge_capacities.console_logs = capacity;
421 self
422 }
423
424 #[must_use]
426 pub fn network_log_capacity(mut self, capacity: usize) -> Self {
427 self.bridge_capacities.network_log = capacity;
428 self
429 }
430
431 #[must_use]
433 pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
434 self.bridge_capacities.navigation_log = capacity;
435 self
436 }
437
438 #[must_use]
453 pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
454 self.commands = schemas.to_vec();
455 self
456 }
457
458 #[must_use]
474 pub fn register_command_names(mut self, names: &[&str]) -> Self {
475 self.commands
476 .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
477 self
478 }
479
480 #[must_use]
496 pub fn auto_discover(mut self) -> Self {
497 self.commands
498 .extend(victauri_core::auto_discovered_commands());
499 self
500 }
501
502 #[must_use]
513 pub fn listen_events(mut self, events: &[&str]) -> Self {
514 self.listen_events = events
515 .iter()
516 .map(std::string::ToString::to_string)
517 .collect();
518 self
519 }
520
521 #[must_use]
530 pub fn allow_file_navigation(mut self) -> Self {
531 self.allow_file_navigation = true;
532 self
533 }
534
535 #[must_use]
548 pub fn db_search_paths<I, P>(mut self, paths: I) -> Self
549 where
550 I: IntoIterator<Item = P>,
551 P: Into<std::path::PathBuf>,
552 {
553 self.db_search_paths
554 .extend(paths.into_iter().map(Into::into));
555 self
556 }
557
558 #[must_use]
582 pub fn probe<F>(mut self, name: impl Into<String>, probe: F) -> Self
583 where
584 F: Fn() -> serde_json::Value + Send + Sync + 'static,
585 {
586 self.probes.push((name.into(), std::sync::Arc::new(probe)));
587 self
588 }
589
590 #[must_use]
593 pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
594 self.on_ready = Some(Box::new(f));
595 self
596 }
597
598 fn resolve_port(&self) -> u16 {
599 self.port
600 .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
601 .unwrap_or(DEFAULT_PORT)
602 }
603
604 fn resolve_auth_token(&self) -> Option<String> {
605 if self.auth_explicitly_disabled {
606 return None;
607 }
608 if let Some(ref token) = self.auth_token
614 && !token.trim().is_empty()
615 {
616 return Some(token.clone());
617 }
618 if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN")
619 && !token.trim().is_empty()
620 {
621 return Some(token);
622 }
623 Some(auth::generate_token())
624 }
625
626 fn resolve_eval_timeout(&self) -> std::time::Duration {
627 std::env::var("VICTAURI_EVAL_TIMEOUT")
628 .ok()
629 .and_then(|s| s.parse::<u64>().ok())
630 .map_or(self.eval_timeout, std::time::Duration::from_secs)
631 }
632
633 fn build_privacy_config(&self) -> privacy::PrivacyConfig {
634 let profile = self
635 .privacy_profile
636 .unwrap_or(privacy::PrivacyProfile::FullControl);
637
638 let redaction_enabled = self.redaction_enabled
639 || self.strict_privacy
640 || matches!(
641 profile,
642 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
643 );
644
645 privacy::PrivacyConfig {
646 profile,
647 command_allowlist: self
648 .command_allowlist
649 .as_ref()
650 .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
651 command_blocklist: self.command_blocklist.iter().cloned().collect(),
652 disabled_tools: self.disabled_tools.iter().cloned().collect(),
653 storage_key_blocklist: self.storage_key_blocklist.iter().cloned().collect(),
654 redactor: redaction::Redactor::new(&self.redaction_patterns),
655 redaction_enabled,
656 }
657 }
658
659 fn validate(&self) -> Result<(), BuilderError> {
660 let port = self.resolve_port();
661 if port == 0 {
662 return Err(BuilderError::InvalidPort {
663 port,
664 reason: "port 0 is reserved".to_string(),
665 });
666 }
667
668 if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
669 return Err(BuilderError::InvalidEventCapacity {
670 capacity: self.event_capacity,
671 reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
672 });
673 }
674
675 if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
676 return Err(BuilderError::InvalidRecorderCapacity {
677 capacity: self.recorder_capacity,
678 reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
679 });
680 }
681
682 let timeout = self.resolve_eval_timeout();
683 if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
684 return Err(BuilderError::InvalidEvalTimeout {
685 timeout_secs: timeout.as_secs(),
686 reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
687 });
688 }
689
690 Ok(())
691 }
692
693 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
703 #[cfg(not(debug_assertions))]
704 {
705 Ok(Builder::new("victauri").build())
706 }
707
708 #[cfg(debug_assertions)]
709 {
710 if env_truthy("VICTAURI_DISABLE") {
714 tracing::info!("Victauri disabled via VICTAURI_DISABLE — returning no-op plugin");
715 return Ok(Builder::new("victauri").build());
716 }
717
718 self.validate()?;
719
720 let port = self.resolve_port();
721 let event_capacity = self.event_capacity;
722 let recorder_capacity = self.recorder_capacity;
723 let eval_timeout = self.resolve_eval_timeout();
724 let auth_token = self.resolve_auth_token();
725 let privacy_config = self.build_privacy_config();
726 let allow_file_navigation = self.allow_file_navigation;
727 let db_search_paths = self.db_search_paths;
728 let on_ready = self.on_ready;
729 let commands = self.commands;
730 let listen_events = self.listen_events;
731 let probes = self.probes;
732 let js_init = js_bridge::init_script(&self.bridge_capacities);
733
734 Ok(Builder::new("victauri")
735 .setup(move |app, _api| {
736 let startup_timeline = introspection::StartupTimeline::new();
737 let event_log = EventLog::new(event_capacity);
738 startup_timeline.mark("event_log_created");
739 let registry = CommandRegistry::new();
740 startup_timeline.mark("registry_created");
741 let (shutdown_tx, shutdown_rx) = watch::channel(false);
742
743 let db_search_paths = resolve_db_search_paths(&db_search_paths);
748
749 let state = Arc::new(VictauriState {
750 event_log,
751 registry,
752 port: AtomicU16::new(port),
753 pending_evals: Arc::new(Mutex::new(HashMap::new())),
754 recorder: EventRecorder::new(recorder_capacity),
755 privacy: privacy_config,
756 eval_timeout,
757 shutdown_tx,
758 started_at: std::time::Instant::now(),
759 tool_invocations: AtomicU64::new(0),
760 allow_file_navigation,
761 command_timings: introspection::CommandTimings::new(),
762 fault_registry: introspection::FaultRegistry::new(),
763 contract_store: introspection::ContractStore::new(),
764 startup_timeline,
765 event_bus: introspection::EventBusMonitor::default(),
766 task_tracker: introspection::TaskTracker::new(),
767 bridge_ready: AtomicBool::new(false),
768 bridge_notify: tokio::sync::Notify::new(),
769 screencast: Arc::new(screencast::Screencast::default()),
770 db_search_paths,
771 probes: introspection::AppStateProbes::default(),
772 });
773 state.startup_timeline.mark("state_created");
774
775 for (name, probe) in probes {
776 state.probes.register(name, probe);
777 }
778
779 app.manage(state.clone());
780
781 for cmd in commands {
782 state.registry.register(cmd);
783 }
784 state.startup_timeline.mark("commands_registered");
785
786 for event_name in &listen_events {
788 let bus = state.event_bus.clone();
789 let name = event_name.clone();
790 app.listen_any(event_name.clone(), move |event| {
791 let payload =
792 serde_json::from_str::<serde_json::Value>(event.payload())
793 .map_or_else(
794 |_| event.payload().to_string(),
795 |v| v.to_string(),
796 );
797 bus.push(introspection::CapturedTauriEvent {
798 name: name.clone(),
799 payload,
800 timestamp: chrono::Utc::now()
801 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
802 });
803 });
804 }
805 state
806 .startup_timeline
807 .mark("event_bus_listeners_registered");
808
809 if let Some(ref token) = auth_token {
810 let prefix_len = token.len().min(8);
811 let suffix_start = token.len().saturating_sub(4);
812 tracing::info!(
813 "Victauri MCP server auth enabled — token: {}…{}",
814 &token[..prefix_len],
815 &token[suffix_start..]
816 );
817 } else {
818 tracing::warn!(
819 "Victauri MCP server running WITHOUT auth — any localhost process can \
820 access all tools. Use VictauriBuilder::auth_enabled() or set \
821 VICTAURI_AUTH_TOKEN for shared/CI environments."
822 );
823 }
824
825 state.startup_timeline.mark("server_spawning");
826 let app_handle = app.clone();
827 let ready_state = state.clone();
828 let server_finished = state.task_tracker.track("mcp_server");
829 tauri::async_runtime::spawn(async move {
830 match mcp::start_server_with_options(
831 app_handle,
832 state,
833 port,
834 auth_token,
835 shutdown_rx,
836 )
837 .await
838 {
839 Ok(()) => {
840 tracing::info!("Victauri MCP server stopped");
841 }
842 Err(e) => {
843 tracing::error!("Victauri MCP server failed: {e}");
844 }
845 }
846 server_finished.store(true, std::sync::atomic::Ordering::Relaxed);
847 });
848
849 if let Some(cb) = on_ready {
850 let ready_finished = ready_state.task_tracker.track("on_ready_probe");
851 tauri::async_runtime::spawn(async move {
852 for _ in 0..50 {
853 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
854 let actual_port =
855 ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
856 if tokio::net::TcpStream::connect(format!(
857 "127.0.0.1:{actual_port}"
858 ))
859 .await
860 .is_ok()
861 {
862 cb(actual_port);
863 ready_finished
864 .store(true, std::sync::atomic::Ordering::Relaxed);
865 return;
866 }
867 }
868 let actual_port =
869 ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
870 tracing::warn!(
871 "Victauri on_ready: server did not become ready within 5s"
872 );
873 cb(actual_port);
874 ready_finished.store(true, std::sync::atomic::Ordering::Relaxed);
875 });
876 }
877
878 emit_security_banner(port);
879 Ok(())
880 })
881 .on_event(|app, event| {
882 let Some(state) = app.try_state::<Arc<VictauriState>>() else {
883 return;
884 };
885 match event {
886 RunEvent::Exit => {
887 let _ = state.shutdown_tx.send(true);
888 tracing::info!("Victauri shutdown signal sent");
889 }
890 RunEvent::ExitRequested { .. } => {
891 state.event_bus.push(introspection::CapturedTauriEvent {
892 name: "tauri://exit-requested".to_string(),
893 payload: String::new(),
894 timestamp: chrono::Utc::now()
895 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
896 });
897 }
898 RunEvent::WindowEvent {
899 label,
900 event: win_event,
901 ..
902 } => {
903 let (name, payload) = format_window_event(label, win_event);
904 state.event_bus.push(introspection::CapturedTauriEvent {
905 name,
906 payload,
907 timestamp: chrono::Utc::now()
908 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
909 });
910 }
911 _ => {}
912 }
913 })
914 .js_init_script(js_init)
915 .invoke_handler(tauri::generate_handler![
916 tools::victauri_eval_js,
917 tools::victauri_eval_callback,
918 tools::victauri_get_window_state,
919 tools::victauri_list_windows,
920 tools::victauri_get_ipc_log,
921 tools::victauri_get_registry,
922 tools::victauri_get_memory_stats,
923 tools::victauri_dom_snapshot,
924 tools::victauri_verify_state,
925 tools::victauri_detect_ghost_commands,
926 tools::victauri_check_ipc_integrity,
927 ])
928 .build())
929 }
930 }
931}
932
933#[cfg(debug_assertions)]
934fn format_window_event(label: &str, event: &tauri::WindowEvent) -> (String, String) {
935 match event {
936 tauri::WindowEvent::Resized(size) => (
937 format!("window:{label}:resized"),
938 serde_json::json!({"width": size.width, "height": size.height}).to_string(),
939 ),
940 tauri::WindowEvent::Moved(pos) => (
941 format!("window:{label}:moved"),
942 serde_json::json!({"x": pos.x, "y": pos.y}).to_string(),
943 ),
944 tauri::WindowEvent::CloseRequested { .. } => {
945 (format!("window:{label}:close-requested"), String::new())
946 }
947 tauri::WindowEvent::Destroyed => (format!("window:{label}:destroyed"), String::new()),
948 tauri::WindowEvent::Focused(focused) => (
949 format!("window:{label}:focused"),
950 serde_json::json!({"focused": focused}).to_string(),
951 ),
952 tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => (
953 format!("window:{label}:scale-factor-changed"),
954 serde_json::json!({"scale_factor": scale_factor}).to_string(),
955 ),
956 tauri::WindowEvent::ThemeChanged(theme) => (
957 format!("window:{label}:theme-changed"),
958 serde_json::json!({"theme": format!("{theme:?}")}).to_string(),
959 ),
960 tauri::WindowEvent::DragDrop(drag_event) => (
961 format!("window:{label}:drag-drop"),
962 format!("{drag_event:?}"),
963 ),
964 _ => (format!("window:{label}:other"), format!("{event:?}")),
965 }
966}
967
968#[cfg(debug_assertions)]
972fn env_truthy(name: &str) -> bool {
973 std::env::var(name).is_ok_and(|v| {
974 matches!(
975 v.trim().to_ascii_lowercase().as_str(),
976 "1" | "true" | "yes" | "on"
977 )
978 })
979}
980
981#[cfg(debug_assertions)]
990fn emit_security_banner(port: u16) {
991 tracing::warn!(
992 "┌─ VICTAURI INTROSPECTION SERVER ACTIVE ─────────────────────────────\n\
993 │ Listening on http://127.0.0.1:{port} — exposes JS eval, IPC, the\n\
994 │ filesystem, and SQLite to local clients. DEBUG-ONLY developer tool;\n\
995 │ it must never reach end users.\n\
996 │ Seeing this in a shipped/release build means your release profile has\n\
997 │ `debug-assertions = true`. Turn that off, or hard-disable Victauri\n\
998 │ with the VICTAURI_DISABLE=1 environment variable.\n\
999 └────────────────────────────────────────────────────────────────────"
1000 );
1001}
1002
1003fn resolve_db_search_paths(configured: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
1026 resolve_db_search_paths_against(configured, &db_search_anchors())
1027}
1028
1029fn db_search_anchors() -> Vec<std::path::PathBuf> {
1032 use std::path::PathBuf;
1033 let mut anchors: Vec<PathBuf> = Vec::new();
1034 if let Ok(cwd) = std::env::current_dir() {
1035 anchors.push(cwd);
1036 }
1037 if let Ok(exe) = std::env::current_exe() {
1038 let mut dir = exe.parent().map(PathBuf::from);
1039 for _ in 0..8 {
1042 match dir {
1043 Some(d) => {
1044 anchors.push(d.clone());
1045 dir = d.parent().map(PathBuf::from);
1046 }
1047 None => break,
1048 }
1049 }
1050 }
1051 anchors
1052}
1053
1054fn resolve_db_search_paths_against(
1058 configured: &[std::path::PathBuf],
1059 anchors: &[std::path::PathBuf],
1060) -> Vec<std::path::PathBuf> {
1061 let mut out: Vec<std::path::PathBuf> = Vec::new();
1062 for p in configured {
1063 if p.is_absolute() {
1064 out.push(p.canonicalize().unwrap_or_else(|_| p.clone()));
1065 } else {
1066 for a in anchors {
1067 if let Ok(c) = a.join(p).canonicalize()
1068 && c.is_dir()
1069 {
1070 out.push(c);
1071 }
1072 }
1073 }
1074 }
1075 out.sort();
1076 out.dedup();
1077 out
1078}
1079
1080#[must_use]
1088pub fn init<R: Runtime>() -> TauriPlugin<R> {
1089 VictauriBuilder::new()
1090 .build()
1091 .expect("default Victauri configuration is always valid")
1092}
1093
1094#[must_use]
1103pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
1104 VictauriBuilder::new()
1105 .auto_discover()
1106 .build()
1107 .expect("default Victauri configuration is always valid")
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use super::*;
1113
1114 #[test]
1115 fn db_search_resolves_relative_path_via_a_non_cwd_anchor() {
1116 use std::path::PathBuf;
1120 let tmp = tempfile::tempdir().unwrap();
1121 let project = tmp.path().join("myapp");
1122 let data = project.join("data");
1123 std::fs::create_dir_all(&data).unwrap();
1124 std::fs::write(data.join("app.db"), b"x").unwrap();
1125 let wrong_cwd = tmp.path().join("elsewhere");
1129 std::fs::create_dir_all(&wrong_cwd).unwrap();
1130 let src_tauri = project.join("src-tauri");
1131 std::fs::create_dir_all(&src_tauri).unwrap();
1132
1133 let configured = vec![PathBuf::from("../data")]; let cwd_only =
1136 resolve_db_search_paths_against(&configured, std::slice::from_ref(&wrong_cwd));
1137 assert!(
1138 cwd_only.is_empty(),
1139 "CWD-only should miss the db, got {cwd_only:?}"
1140 );
1141 let multi = resolve_db_search_paths_against(&configured, &[wrong_cwd, src_tauri]);
1143 let want = data.canonicalize().unwrap();
1144 assert!(
1145 multi.contains(&want),
1146 "multi-anchor should resolve ../data to {want:?}, got {multi:?}"
1147 );
1148 }
1149
1150 #[test]
1151 fn db_search_passes_absolute_and_dedups() {
1152 use std::path::PathBuf;
1153 let tmp = tempfile::tempdir().unwrap();
1154 let data = tmp.path().join("data");
1155 std::fs::create_dir_all(&data).unwrap();
1156 let abs = data.canonicalize().unwrap();
1157 let out = resolve_db_search_paths_against(
1159 &[abs.clone(), PathBuf::from("data")],
1160 &[tmp.path().to_path_buf(), tmp.path().to_path_buf()],
1161 );
1162 assert_eq!(
1163 out.iter().filter(|p| **p == abs).count(),
1164 1,
1165 "must dedupe: {out:?}"
1166 );
1167 }
1168
1169 #[test]
1170 fn builder_default_values() {
1171 let builder = VictauriBuilder::new();
1172 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
1173 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
1174 assert!(builder.auth_token.is_none());
1175 assert!(!builder.auth_explicitly_enabled);
1176 assert!(!builder.auth_explicitly_disabled);
1177 let resolved = builder.resolve_auth_token();
1178 assert!(
1179 resolved.is_some(),
1180 "auth should be enabled by default (auto-generated token)"
1181 );
1182 assert_eq!(
1183 resolved.unwrap().len(),
1184 36,
1185 "auto-generated token should be UUID v4"
1186 );
1187 assert!(builder.disabled_tools.is_empty());
1188 assert!(builder.command_allowlist.is_none());
1189 assert!(builder.command_blocklist.is_empty());
1190 assert!(!builder.redaction_enabled);
1191 assert!(!builder.strict_privacy);
1192 }
1193
1194 #[test]
1195 fn builder_port_override() {
1196 let builder = VictauriBuilder::new().port(9090);
1197 assert_eq!(builder.resolve_port(), 9090);
1198 }
1199
1200 #[test]
1201 #[allow(unsafe_code)]
1202 fn builder_default_port() {
1203 let builder = VictauriBuilder::new();
1204 unsafe { std::env::remove_var("VICTAURI_PORT") };
1206 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
1207 }
1208
1209 #[test]
1210 fn builder_auth_token_explicit() {
1211 let builder = VictauriBuilder::new().auth_token("my-secret");
1212 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
1213 }
1214
1215 #[cfg(debug_assertions)]
1219 #[test]
1220 #[allow(unsafe_code)]
1221 fn env_truthy_recognizes_kill_switch_values() {
1222 let key = "VICTAURI_TEST_KILL_SWITCH";
1224 unsafe { std::env::remove_var(key) };
1226 assert!(!env_truthy(key), "unset should be false");
1227
1228 for v in ["1", "true", "TRUE", " yes ", "On"] {
1229 unsafe { std::env::set_var(key, v) };
1231 assert!(env_truthy(key), "{v:?} should be truthy");
1232 }
1233 for v in ["0", "false", "", "nope"] {
1234 unsafe { std::env::set_var(key, v) };
1236 assert!(!env_truthy(key), "{v:?} should be falsy");
1237 }
1238 unsafe { std::env::remove_var(key) };
1240 }
1241
1242 #[test]
1243 fn builder_auth_enabled() {
1244 let builder = VictauriBuilder::new().auth_enabled();
1245 assert!(builder.auth_explicitly_enabled);
1246 let token = builder.resolve_auth_token().unwrap();
1247 assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
1248 }
1249
1250 #[test]
1251 fn builder_auth_generate_token() {
1252 let builder = VictauriBuilder::new().generate_auth_token();
1253 let token = builder.resolve_auth_token().unwrap();
1254 assert_eq!(token.len(), 36);
1255 }
1256
1257 #[test]
1258 fn builder_auth_disabled_suppresses_default_token() {
1259 let builder = VictauriBuilder::new().auth_disabled();
1260 assert!(
1261 builder.resolve_auth_token().is_none(),
1262 "auth_disabled must suppress the default auto-generated token (auth is ON by default)"
1263 );
1264 }
1265
1266 #[test]
1267 fn builder_auth_disabled_returns_none() {
1268 let builder = VictauriBuilder::new().auth_disabled();
1269 assert!(
1270 builder.resolve_auth_token().is_none(),
1271 "auth_disabled should suppress auto-generated token"
1272 );
1273 }
1274
1275 #[test]
1276 fn builder_auth_disabled_overrides_explicit_token() {
1277 let builder = VictauriBuilder::new()
1278 .auth_token("my-secret")
1279 .auth_disabled();
1280 assert!(
1281 builder.resolve_auth_token().is_none(),
1282 "auth_disabled should override explicit token"
1283 );
1284 }
1285
1286 #[test]
1287 fn builder_empty_explicit_token_does_not_disable_auth() {
1288 for blank in ["", " ", "\t\n"] {
1291 let resolved = VictauriBuilder::new()
1292 .auth_token(blank)
1293 .resolve_auth_token();
1294 assert!(
1295 resolved.as_deref().is_some_and(|t| !t.trim().is_empty()),
1296 "empty explicit token {blank:?} must resolve to a generated token, not no-auth"
1297 );
1298 }
1299 }
1300
1301 #[test]
1302 fn builder_capacities() {
1303 let builder = VictauriBuilder::new()
1304 .event_capacity(500)
1305 .recorder_capacity(2000);
1306 assert_eq!(builder.event_capacity, 500);
1307 assert_eq!(builder.recorder_capacity, 2000);
1308 }
1309
1310 #[test]
1311 fn builder_disable_tools() {
1312 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
1313 assert_eq!(builder.disabled_tools.len(), 2);
1314 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
1315 }
1316
1317 #[test]
1318 fn builder_command_allowlist() {
1319 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
1320 assert!(builder.command_allowlist.is_some());
1321 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
1322 }
1323
1324 #[test]
1325 fn builder_command_blocklist() {
1326 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
1327 assert_eq!(builder.command_blocklist.len(), 1);
1328 }
1329
1330 #[test]
1331 fn builder_redaction() {
1332 let builder = VictauriBuilder::new()
1333 .add_redaction_pattern(r"SECRET_\w+")
1334 .enable_redaction();
1335 assert!(builder.redaction_enabled);
1336 assert_eq!(builder.redaction_patterns.len(), 1);
1337 }
1338
1339 #[test]
1340 fn builder_strict_privacy_config() {
1341 let builder = VictauriBuilder::new().strict_privacy_mode();
1342 let config = builder.build_privacy_config();
1343 assert!(config.redaction_enabled);
1344 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
1345 assert!(!config.is_tool_enabled("eval_js"));
1346 assert!(!config.is_tool_enabled("screenshot"));
1347 assert!(!config.is_tool_enabled("interact"));
1348 assert!(config.is_tool_enabled("dom_snapshot"));
1349 }
1350
1351 #[test]
1352 fn builder_normal_privacy_config() {
1353 let builder = VictauriBuilder::new()
1354 .command_blocklist(&["secret_cmd"])
1355 .disable_tools(&["eval_js"]);
1356 let config = builder.build_privacy_config();
1357 assert!(config.command_blocklist.contains("secret_cmd"));
1358 assert!(!config.is_tool_enabled("eval_js"));
1359 assert!(!config.redaction_enabled);
1360 }
1361
1362 #[test]
1363 fn builder_strict_with_extra_blocklist() {
1364 let builder = VictauriBuilder::new()
1365 .strict_privacy_mode()
1366 .command_blocklist(&["extra_dangerous"]);
1367 let config = builder.build_privacy_config();
1368 assert!(config.command_blocklist.contains("extra_dangerous"));
1369 assert!(!config.is_tool_enabled("eval_js"));
1370 }
1371
1372 #[test]
1373 fn builder_test_profile() {
1374 let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
1375 let config = builder.build_privacy_config();
1376 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
1377 assert!(config.redaction_enabled);
1378 assert!(config.is_tool_enabled("interact"));
1379 assert!(config.is_tool_enabled("fill"));
1380 assert!(config.is_tool_enabled("recording"));
1381 assert!(!config.is_tool_enabled("eval_js"));
1382 assert!(!config.is_tool_enabled("screenshot"));
1383 assert!(!config.is_tool_enabled("navigate.go_to"));
1386 }
1387
1388 #[test]
1389 fn builder_profile_with_extra_disables() {
1390 let builder = VictauriBuilder::new()
1391 .privacy_profile(crate::privacy::PrivacyProfile::Test)
1392 .disable_tools(&["interact"]);
1393 let config = builder.build_privacy_config();
1394 assert!(!config.is_tool_enabled("interact"));
1395 assert!(config.is_tool_enabled("fill"));
1396 }
1397
1398 #[test]
1399 fn builder_bridge_capacities() {
1400 let builder = VictauriBuilder::new()
1401 .console_log_capacity(5000)
1402 .network_log_capacity(2000)
1403 .navigation_log_capacity(500);
1404 assert_eq!(builder.bridge_capacities.console_logs, 5000);
1405 assert_eq!(builder.bridge_capacities.network_log, 2000);
1406 assert_eq!(builder.bridge_capacities.navigation_log, 500);
1407 assert_eq!(builder.bridge_capacities.mutation_log, 500);
1408 assert_eq!(builder.bridge_capacities.dialog_log, 100);
1409 }
1410
1411 #[test]
1412 fn builder_on_ready_sets_callback() {
1413 let builder = VictauriBuilder::new().on_ready(|_port| {});
1414 assert!(builder.on_ready.is_some());
1415 }
1416
1417 #[test]
1418 fn builder_file_navigation_disabled_by_default() {
1419 let builder = VictauriBuilder::new();
1420 assert!(
1421 !builder.allow_file_navigation,
1422 "file navigation should be disabled by default"
1423 );
1424 }
1425
1426 #[test]
1427 fn builder_allow_file_navigation() {
1428 let builder = VictauriBuilder::new().allow_file_navigation();
1429 assert!(builder.allow_file_navigation);
1430 }
1431
1432 #[test]
1433 fn builder_listen_events() {
1434 let builder =
1435 VictauriBuilder::new().listen_events(&["notification-added", "settings-changed"]);
1436 assert_eq!(builder.listen_events.len(), 2);
1437 assert!(
1438 builder
1439 .listen_events
1440 .contains(&"notification-added".to_string())
1441 );
1442 assert!(
1443 builder
1444 .listen_events
1445 .contains(&"settings-changed".to_string())
1446 );
1447 }
1448
1449 #[test]
1450 fn builder_listen_events_empty_by_default() {
1451 let builder = VictauriBuilder::new();
1452 assert!(builder.listen_events.is_empty());
1453 }
1454
1455 #[test]
1456 fn init_script_contains_custom_capacities() {
1457 let caps = js_bridge::BridgeCapacities {
1458 console_logs: 3000,
1459 mutation_log: 750,
1460 network_log: 5000,
1461 navigation_log: 400,
1462 dialog_log: 250,
1463 long_tasks: 200,
1464 };
1465 let script = js_bridge::init_script(&caps);
1466 assert!(script.contains("CAP_CONSOLE = 3000"));
1467 assert!(script.contains("CAP_MUTATION = 750"));
1468 assert!(script.contains("CAP_NETWORK = 5000"));
1469 assert!(script.contains("CAP_NAVIGATION = 400"));
1470 assert!(script.contains("CAP_DIALOG = 250"));
1471 assert!(script.contains("CAP_LONG_TASKS = 200"));
1472 }
1473
1474 #[test]
1475 fn init_script_default_contains_standard_capacities() {
1476 let caps = js_bridge::BridgeCapacities::default();
1477 let script = js_bridge::init_script(&caps);
1478 assert!(script.contains("CAP_CONSOLE = 1000"));
1479 assert!(script.contains("CAP_NETWORK = 1000"));
1480 assert!(script.contains("window.__VICTAURI__"));
1481 }
1482
1483 #[test]
1484 fn builder_validates_defaults() {
1485 let builder = VictauriBuilder::new();
1486 assert!(builder.validate().is_ok());
1487 }
1488
1489 #[test]
1490 fn builder_rejects_zero_port() {
1491 let builder = VictauriBuilder::new().port(0);
1492 let err = builder.validate().unwrap_err();
1493 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
1494 }
1495
1496 #[test]
1497 fn builder_rejects_zero_event_capacity() {
1498 let builder = VictauriBuilder::new().event_capacity(0);
1499 let err = builder.validate().unwrap_err();
1500 assert!(matches!(
1501 err,
1502 BuilderError::InvalidEventCapacity { capacity: 0, .. }
1503 ));
1504 }
1505
1506 #[test]
1507 fn builder_rejects_excessive_event_capacity() {
1508 let builder = VictauriBuilder::new().event_capacity(2_000_000);
1509 assert!(builder.validate().is_err());
1510 }
1511
1512 #[test]
1513 fn builder_rejects_zero_recorder_capacity() {
1514 let builder = VictauriBuilder::new().recorder_capacity(0);
1515 assert!(builder.validate().is_err());
1516 }
1517
1518 #[test]
1519 fn builder_rejects_zero_eval_timeout() {
1520 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
1521 assert!(builder.validate().is_err());
1522 }
1523
1524 #[test]
1525 fn builder_rejects_excessive_eval_timeout() {
1526 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1527 assert!(builder.validate().is_err());
1528 }
1529
1530 #[test]
1531 fn builder_accepts_edge_values() {
1532 let builder = VictauriBuilder::new()
1533 .port(1)
1534 .event_capacity(1)
1535 .recorder_capacity(1)
1536 .eval_timeout(std::time::Duration::from_secs(1));
1537 assert!(builder.validate().is_ok());
1538
1539 let builder = VictauriBuilder::new()
1540 .port(65535)
1541 .event_capacity(MAX_EVENT_CAPACITY)
1542 .recorder_capacity(MAX_RECORDER_CAPACITY)
1543 .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1544 assert!(builder.validate().is_ok());
1545 }
1546}