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
97pub use error::BuilderError;
98pub use privacy::PrivacyProfile;
99
100pub use victauri_core::CommandInfo;
101pub use victauri_macros::inspectable;
102
103#[macro_export]
124macro_rules! register_commands {
125 ($app:expr, $($schema_call:expr),+ $(,)?) => {{
126 if let Some(state) = $app.try_state::<std::sync::Arc<$crate::VictauriState>>() {
130 $(
131 state.registry.register($schema_call);
132 )+
133 }
134 }};
135}
136
137const DEFAULT_PORT: u16 = 7373;
138const DEFAULT_EVENT_CAPACITY: usize = 10_000;
139const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
140const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
141const MAX_EVENT_CAPACITY: usize = 1_000_000;
142const MAX_RECORDER_CAPACITY: usize = 1_000_000;
143const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
144
145#[doc(hidden)]
151pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
152
153pub(crate) struct PendingSlot {
160 map: PendingCallbacks,
161 id: String,
162}
163
164impl PendingSlot {
165 pub(crate) fn insert(
168 map: &PendingCallbacks,
169 locked: &mut HashMap<String, oneshot::Sender<String>>,
170 id: String,
171 tx: oneshot::Sender<String>,
172 ) -> Self {
173 locked.insert(id.clone(), tx);
174 Self {
175 map: Arc::clone(map),
176 id,
177 }
178 }
179
180 pub(crate) fn id(&self) -> &str {
182 &self.id
183 }
184
185 pub(crate) fn bind_window(&self, label: Option<&str>) {
190 let target = label.map_or(EvalTarget::DefaultWindow, |l| {
191 EvalTarget::Window(l.to_string())
192 });
193 eval_targets().insert(self.id.clone(), target);
194 }
195}
196
197#[derive(Clone, Debug, PartialEq, Eq)]
199enum EvalTarget {
200 Window(String),
202 DefaultWindow,
204}
205
206static EVAL_TARGETS: std::sync::LazyLock<std::sync::Mutex<HashMap<String, EvalTarget>>> =
209 std::sync::LazyLock::new(Default::default);
210
211fn eval_targets() -> std::sync::MutexGuard<'static, HashMap<String, EvalTarget>> {
212 EVAL_TARGETS
213 .lock()
214 .unwrap_or_else(std::sync::PoisonError::into_inner)
215}
216
217pub(crate) fn eval_result_accepted(
222 id: &str,
223 caller: &str,
224 default_label: impl FnOnce() -> Option<String>,
225) -> bool {
226 let target = eval_targets().get(id).cloned();
227 match target {
228 Some(EvalTarget::Window(label)) => label == caller,
229 Some(EvalTarget::DefaultWindow) => default_label().as_deref() == Some(caller),
230 None => false,
231 }
232}
233
234impl Drop for PendingSlot {
235 fn drop(&mut self) {
236 eval_targets().remove(&self.id);
237 if let Ok(mut map) = self.map.try_lock() {
240 map.remove(&self.id);
241 return;
242 }
243 let map = Arc::clone(&self.map);
244 let id = std::mem::take(&mut self.id);
245 match tokio::runtime::Handle::try_current() {
246 Ok(rt) => {
247 rt.spawn(async move {
248 map.lock().await.remove(&id);
249 });
250 }
251 Err(_) => {
252 map.blocking_lock().remove(&id);
253 }
254 }
255 }
256}
257
258#[non_exhaustive]
264pub struct VictauriState {
265 pub event_log: EventLog,
267 pub registry: CommandRegistry,
269 pub port: AtomicU16,
271 pub pending_evals: PendingCallbacks,
273 pub recorder: EventRecorder,
275 pub privacy: privacy::PrivacyConfig,
277 pub eval_timeout: std::time::Duration,
279 pub shutdown_tx: watch::Sender<bool>,
281 pub started_at: std::time::Instant,
283 pub tool_invocations: AtomicU64,
285 pub allow_file_navigation: bool,
289 pub command_timings: introspection::CommandTimings,
291 pub fault_registry: introspection::FaultRegistry,
293 pub contract_store: introspection::ContractStore,
295 pub startup_timeline: introspection::StartupTimeline,
297 pub event_bus: introspection::EventBusMonitor,
299 pub task_tracker: introspection::TaskTracker,
301 pub bridge_ready: AtomicBool,
303 pub bridge_notify: tokio::sync::Notify,
305 pub screencast: Arc<screencast::Screencast>,
307 pub db_search_paths: Vec<std::path::PathBuf>,
314 pub probes: introspection::AppStateProbes,
317 pub(crate) drain_watermarks: introspection::DrainWatermarks,
319 pub(crate) page_loads: introspection::PageLoads,
321}
322
323impl VictauriState {
324 #[doc(hidden)]
335 #[must_use]
336 pub fn for_tests() -> Self {
337 Self {
338 event_log: EventLog::new(1000),
339 registry: CommandRegistry::new(),
340 port: AtomicU16::new(0),
341 pending_evals: Arc::new(Mutex::new(HashMap::new())),
342 recorder: EventRecorder::new(1000),
343 privacy: privacy::PrivacyConfig::default(),
344 eval_timeout: std::time::Duration::from_secs(30),
345 shutdown_tx: watch::channel(false).0,
346 started_at: std::time::Instant::now(),
347 tool_invocations: AtomicU64::new(0),
348 allow_file_navigation: false,
349 command_timings: introspection::CommandTimings::new(),
350 fault_registry: introspection::FaultRegistry::new(),
351 contract_store: introspection::ContractStore::new(),
352 startup_timeline: introspection::StartupTimeline::new(),
353 event_bus: introspection::EventBusMonitor::default(),
354 task_tracker: introspection::TaskTracker::new(),
355 bridge_ready: AtomicBool::new(true),
356 bridge_notify: tokio::sync::Notify::new(),
357 screencast: Arc::new(screencast::Screencast::default()),
358 db_search_paths: Vec::new(),
359 probes: introspection::AppStateProbes::default(),
360 drain_watermarks: introspection::DrainWatermarks::default(),
361 page_loads: introspection::PageLoads::default(),
362 }
363 }
364}
365
366pub struct VictauriBuilder {
380 port: Option<u16>,
381 event_capacity: usize,
382 recorder_capacity: usize,
383 eval_timeout: std::time::Duration,
384 auth_token: Option<String>,
385 auth_explicitly_enabled: bool,
386 auth_explicitly_disabled: bool,
387 disabled_tools: Vec<String>,
388 command_allowlist: Option<Vec<String>>,
389 command_blocklist: Vec<String>,
390 storage_key_blocklist: Vec<String>,
391 redaction_patterns: Vec<String>,
392 redaction_enabled: bool,
393 strict_privacy: bool,
394 privacy_profile: Option<privacy::PrivacyProfile>,
395 bridge_capacities: js_bridge::BridgeCapacities,
396 on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
397 commands: Vec<victauri_core::CommandInfo>,
398 allow_file_navigation: bool,
399 listen_events: Vec<String>,
400 db_search_paths: Vec<std::path::PathBuf>,
401 probes: Vec<(String, std::sync::Arc<introspection::ProbeFn>)>,
402}
403
404impl Default for VictauriBuilder {
405 fn default() -> Self {
406 Self {
407 port: None,
408 event_capacity: DEFAULT_EVENT_CAPACITY,
409 recorder_capacity: DEFAULT_RECORDER_CAPACITY,
410 eval_timeout: DEFAULT_EVAL_TIMEOUT,
411 auth_token: None,
412 auth_explicitly_enabled: false,
413 auth_explicitly_disabled: false,
414 disabled_tools: Vec::new(),
415 command_allowlist: None,
416 command_blocklist: Vec::new(),
417 storage_key_blocklist: Vec::new(),
418 redaction_patterns: Vec::new(),
419 redaction_enabled: false,
420 strict_privacy: false,
421 privacy_profile: None,
422 bridge_capacities: js_bridge::BridgeCapacities::default(),
423 on_ready: None,
424 commands: Vec::new(),
425 allow_file_navigation: false,
426 listen_events: Vec::new(),
427 db_search_paths: Vec::new(),
428 probes: Vec::new(),
429 }
430 }
431}
432
433impl VictauriBuilder {
434 #[must_use]
436 pub fn new() -> Self {
437 Self::default()
438 }
439
440 #[must_use]
442 pub fn port(mut self, port: u16) -> Self {
443 self.port = Some(port);
444 self
445 }
446
447 #[must_use]
449 pub fn event_capacity(mut self, capacity: usize) -> Self {
450 self.event_capacity = capacity;
451 self
452 }
453
454 #[must_use]
456 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
457 self.recorder_capacity = capacity;
458 self
459 }
460
461 #[must_use]
463 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
464 self.eval_timeout = timeout;
465 self
466 }
467
468 #[must_use]
472 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
473 self.auth_token = Some(token.into());
474 self
475 }
476
477 #[must_use]
487 pub fn auth_enabled(mut self) -> Self {
488 self.auth_explicitly_enabled = true;
489 self
490 }
491
492 #[must_use]
494 pub fn generate_auth_token(mut self) -> Self {
495 self.auth_explicitly_enabled = true;
496 self
497 }
498
499 #[must_use]
509 pub fn auth_disabled(mut self) -> Self {
510 self.auth_explicitly_disabled = true;
511 self
512 }
513
514 #[must_use]
520 pub fn disable_tools(mut self, tools: &[&str]) -> Self {
521 for unknown in mcp::authz::unknown_names(tools.iter().copied()) {
522 tracing::warn!(
523 name = unknown,
524 "disable_tools: {unknown:?} is not a Victauri tool, `tool.action`, or capability \
525 name, so it disables nothing (check the spelling against the tool list)"
526 );
527 }
528 self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
529 self
530 }
531
532 #[must_use]
534 pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
535 self.command_allowlist = Some(
536 commands
537 .iter()
538 .map(std::string::ToString::to_string)
539 .collect(),
540 );
541 self
542 }
543
544 #[must_use]
546 pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
547 self.command_blocklist = commands
548 .iter()
549 .map(std::string::ToString::to_string)
550 .collect();
551 self
552 }
553
554 #[must_use]
559 pub fn storage_key_blocklist(mut self, keys: &[&str]) -> Self {
560 self.storage_key_blocklist = keys.iter().map(std::string::ToString::to_string).collect();
561 self
562 }
563
564 #[must_use]
566 pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
567 self.redaction_patterns.push(pattern.into());
568 self
569 }
570
571 #[must_use]
573 pub fn enable_redaction(mut self) -> Self {
574 self.redaction_enabled = true;
575 self
576 }
577
578 #[must_use]
585 pub fn strict_privacy_mode(mut self) -> Self {
586 self.strict_privacy = true;
587 self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
588 self
589 }
590
591 #[must_use]
600 pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
601 self.privacy_profile = Some(profile);
602 if matches!(
603 profile,
604 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
605 ) {
606 self.redaction_enabled = true;
607 }
608 self
609 }
610
611 #[must_use]
613 pub fn console_log_capacity(mut self, capacity: usize) -> Self {
614 self.bridge_capacities.console_logs = capacity;
615 self
616 }
617
618 #[must_use]
620 pub fn network_log_capacity(mut self, capacity: usize) -> Self {
621 self.bridge_capacities.network_log = capacity;
622 self
623 }
624
625 #[must_use]
627 pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
628 self.bridge_capacities.navigation_log = capacity;
629 self
630 }
631
632 #[must_use]
647 pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
648 self.commands = schemas.to_vec();
649 self
650 }
651
652 #[must_use]
668 pub fn register_command_names(mut self, names: &[&str]) -> Self {
669 self.commands
670 .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
671 self
672 }
673
674 #[must_use]
690 pub fn auto_discover(mut self) -> Self {
691 self.commands
692 .extend(victauri_core::auto_discovered_commands());
693 self
694 }
695
696 #[must_use]
707 pub fn listen_events(mut self, events: &[&str]) -> Self {
708 self.listen_events = events
709 .iter()
710 .map(std::string::ToString::to_string)
711 .collect();
712 self
713 }
714
715 #[must_use]
724 pub fn allow_file_navigation(mut self) -> Self {
725 self.allow_file_navigation = true;
726 self
727 }
728
729 #[must_use]
742 pub fn db_search_paths<I, P>(mut self, paths: I) -> Self
743 where
744 I: IntoIterator<Item = P>,
745 P: Into<std::path::PathBuf>,
746 {
747 self.db_search_paths
748 .extend(paths.into_iter().map(Into::into));
749 self
750 }
751
752 #[must_use]
786 pub fn probe<F>(mut self, name: impl Into<String>, probe: F) -> Self
787 where
788 F: Fn() -> serde_json::Value + Send + Sync + 'static,
789 {
790 self.probes.push((name.into(), std::sync::Arc::new(probe)));
791 self
792 }
793
794 #[must_use]
807 pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
808 self.on_ready = Some(Box::new(f));
809 self
810 }
811
812 fn resolve_port(&self) -> u16 {
813 self.port
814 .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
815 .unwrap_or(DEFAULT_PORT)
816 }
817
818 fn resolve_auth_token(&self) -> Option<String> {
819 if self.auth_explicitly_disabled {
820 return None;
821 }
822 if let Some(ref token) = self.auth_token
828 && !token.trim().is_empty()
829 {
830 return Some(token.clone());
831 }
832 if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN")
833 && !token.trim().is_empty()
834 {
835 return Some(token);
836 }
837 Some(auth::generate_token())
838 }
839
840 fn resolve_eval_timeout(&self) -> std::time::Duration {
841 std::env::var("VICTAURI_EVAL_TIMEOUT")
842 .ok()
843 .and_then(|s| s.parse::<u64>().ok())
844 .map_or(self.eval_timeout, std::time::Duration::from_secs)
845 }
846
847 fn build_privacy_config(&self) -> privacy::PrivacyConfig {
848 let profile = self
849 .privacy_profile
850 .unwrap_or(privacy::PrivacyProfile::FullControl);
851
852 let redaction_enabled = self.redaction_enabled
853 || self.strict_privacy
854 || matches!(
855 profile,
856 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
857 );
858
859 privacy::PrivacyConfig {
860 profile,
861 command_allowlist: self
862 .command_allowlist
863 .as_ref()
864 .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
865 command_blocklist: self.command_blocklist.iter().cloned().collect(),
866 disabled_tools: self.disabled_tools.iter().cloned().collect(),
867 storage_key_blocklist: self.storage_key_blocklist.iter().cloned().collect(),
868 redactor: redaction::Redactor::new(&self.redaction_patterns),
869 redaction_enabled,
870 }
871 }
872
873 fn validate(&self) -> Result<(), BuilderError> {
874 let port = self.resolve_port();
875 if port == 0 {
876 return Err(BuilderError::InvalidPort {
877 port,
878 reason: "port 0 is reserved".to_string(),
879 });
880 }
881
882 if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
883 return Err(BuilderError::InvalidEventCapacity {
884 capacity: self.event_capacity,
885 reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
886 });
887 }
888
889 if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
890 return Err(BuilderError::InvalidRecorderCapacity {
891 capacity: self.recorder_capacity,
892 reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
893 });
894 }
895
896 let timeout = self.resolve_eval_timeout();
897 if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
898 return Err(BuilderError::InvalidEvalTimeout {
899 timeout_secs: timeout.as_secs(),
900 reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
901 });
902 }
903
904 Ok(())
905 }
906
907 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
917 #[cfg(not(debug_assertions))]
918 {
919 Ok(Builder::new("victauri").build())
920 }
921
922 #[cfg(debug_assertions)]
923 {
924 if env_truthy("VICTAURI_DISABLE") {
928 tracing::info!("Victauri disabled via VICTAURI_DISABLE — returning no-op plugin");
929 return Ok(Builder::new("victauri").build());
930 }
931
932 self.validate()?;
933
934 let port = self.resolve_port();
935 let event_capacity = self.event_capacity;
936 let recorder_capacity = self.recorder_capacity;
937 let eval_timeout = self.resolve_eval_timeout();
938 let auth_token = self.resolve_auth_token();
939 let privacy_config = self.build_privacy_config();
940 let allow_file_navigation = self.allow_file_navigation;
941 let db_search_paths = self.db_search_paths;
942 let on_ready = self.on_ready;
943 let commands = self.commands;
944 let listen_events = self.listen_events;
945 let probes = self.probes;
946 let js_init = js_bridge::init_script(&self.bridge_capacities);
947
948 Ok(Builder::new("victauri")
949 .setup(move |app, _api| {
950 match bridge::adopt_main_thread(app) {
956 Ok(bridge::MainThreadAdoption::Inline) => {}
957 Ok(bridge::MainThreadAdoption::Posted) => tracing::warn!(
958 "Victauri: plugin setup ran off the main thread (registered at \
959 runtime from a background thread?); the main-thread state is being \
960 installed on the main thread — webview/window tools called before \
961 it is will fail with \"not installed\""
962 ),
963 Err(e) => tracing::error!(
964 "Victauri: could not reach the main thread to install its state \
965 ({e}); webview and window tools will fail with \"not installed\""
966 ),
967 }
968 let startup_timeline = introspection::StartupTimeline::new();
969 let event_log = EventLog::new(event_capacity);
970 startup_timeline.mark("event_log_created");
971 let registry = CommandRegistry::new();
972 startup_timeline.mark("registry_created");
973 let (shutdown_tx, shutdown_rx) = watch::channel(false);
974
975 let db_search_paths = resolve_db_search_paths(&db_search_paths);
980
981 let state = Arc::new(VictauriState {
982 event_log,
983 registry,
984 port: AtomicU16::new(port),
985 pending_evals: Arc::new(Mutex::new(HashMap::new())),
986 recorder: EventRecorder::new(recorder_capacity),
987 privacy: privacy_config,
988 eval_timeout,
989 shutdown_tx,
990 started_at: std::time::Instant::now(),
991 tool_invocations: AtomicU64::new(0),
992 allow_file_navigation,
993 command_timings: introspection::CommandTimings::new(),
994 fault_registry: introspection::FaultRegistry::new(),
995 contract_store: introspection::ContractStore::new(),
996 startup_timeline,
997 event_bus: introspection::EventBusMonitor::default(),
998 task_tracker: introspection::TaskTracker::new(),
999 bridge_ready: AtomicBool::new(false),
1000 bridge_notify: tokio::sync::Notify::new(),
1001 screencast: Arc::new(screencast::Screencast::default()),
1002 db_search_paths,
1003 probes: introspection::AppStateProbes::default(),
1004 drain_watermarks: introspection::DrainWatermarks::default(),
1005 page_loads: introspection::PageLoads::default(),
1006 });
1007 state.startup_timeline.mark("state_created");
1008
1009 for (name, probe) in probes {
1010 state.probes.register(name, probe);
1011 }
1012
1013 app.manage(state.clone());
1014
1015 for cmd in commands {
1016 state.registry.register(cmd);
1017 }
1018 state.startup_timeline.mark("commands_registered");
1019
1020 for event_name in &listen_events {
1022 let bus = state.event_bus.clone();
1023 let name = event_name.clone();
1024 app.listen_any(event_name.clone(), move |event| {
1025 let payload =
1026 serde_json::from_str::<serde_json::Value>(event.payload())
1027 .map_or_else(
1028 |_| event.payload().to_string(),
1029 |v| v.to_string(),
1030 );
1031 bus.push(introspection::CapturedTauriEvent {
1032 name: name.clone(),
1033 payload,
1034 timestamp: chrono::Utc::now()
1035 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1036 });
1037 });
1038 }
1039 state
1040 .startup_timeline
1041 .mark("event_bus_listeners_registered");
1042
1043 if auth_token.is_some() {
1044 tracing::info!(
1047 "Victauri MCP server auth enabled — clients read the token from \
1048 the discovery directory"
1049 );
1050 } else {
1051 tracing::warn!(
1052 "Victauri MCP server running WITHOUT auth — any localhost process can \
1053 access all tools. Use VictauriBuilder::auth_enabled() or set \
1054 VICTAURI_AUTH_TOKEN for shared/CI environments."
1055 );
1056 }
1057
1058 state.startup_timeline.mark("server_spawning");
1059 let app_handle = app.clone();
1060 let ready_state = state.clone();
1061 let server_finished = state.task_tracker.track_guarded("mcp_server");
1062 let (bound_tx, bound_rx) = tokio::sync::oneshot::channel();
1063 tauri::async_runtime::spawn(async move {
1064 let _finished = server_finished;
1065 match mcp::start_server_reporting_port(
1066 app_handle,
1067 state,
1068 port,
1069 auth_token,
1070 shutdown_rx,
1071 Some(bound_tx),
1072 )
1073 .await
1074 {
1075 Ok(()) => {
1076 tracing::info!("Victauri MCP server stopped");
1077 }
1078 Err(e) => {
1079 tracing::error!("Victauri MCP server failed: {e}");
1080 }
1081 }
1082 });
1083
1084 let ready_finished = ready_state.task_tracker.track_guarded("on_ready_probe");
1089 tauri::async_runtime::spawn(async move {
1090 let _finished = ready_finished;
1091 match tokio::time::timeout(std::time::Duration::from_secs(5), bound_rx)
1092 .await
1093 {
1094 Ok(Ok(Ok(actual_port))) => {
1095 emit_security_banner(actual_port);
1096 if let Some(cb) = on_ready {
1097 cb(actual_port);
1098 }
1099 }
1100 Ok(Ok(Err(e))) => tracing::warn!(
1101 "Victauri: server did not start ({e}); on_ready not called"
1102 ),
1103 Ok(Err(_)) | Err(_) => tracing::warn!(
1104 "Victauri: server did not report a bound port within 5s; \
1105 on_ready not called"
1106 ),
1107 }
1108 });
1109 Ok(())
1110 })
1111 .on_event(|app, event| {
1112 let Some(state) = app.try_state::<Arc<VictauriState>>() else {
1113 return;
1114 };
1115 match event {
1116 RunEvent::Exit => {
1117 state.shutdown_tx.send_replace(true);
1121 tracing::info!("Victauri shutdown signal sent");
1122 }
1123 RunEvent::ExitRequested { .. } => {
1124 state.event_bus.push(introspection::CapturedTauriEvent {
1125 name: "tauri://exit-requested".to_string(),
1126 payload: String::new(),
1127 timestamp: chrono::Utc::now()
1128 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1129 });
1130 }
1131 RunEvent::WindowEvent {
1132 label,
1133 event: win_event,
1134 ..
1135 } => {
1136 let (name, payload) = format_window_event(label, win_event);
1137 state.event_bus.push(introspection::CapturedTauriEvent {
1138 name,
1139 payload,
1140 timestamp: chrono::Utc::now()
1141 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1142 });
1143 }
1144 _ => {}
1145 }
1146 })
1147 .js_init_script(js_init)
1148 .invoke_handler(tauri::generate_handler![
1149 tools::victauri_eval_js,
1150 tools::victauri_eval_callback,
1151 tools::victauri_get_window_state,
1152 tools::victauri_list_windows,
1153 tools::victauri_get_ipc_log,
1154 tools::victauri_get_registry,
1155 tools::victauri_get_memory_stats,
1156 tools::victauri_dom_snapshot,
1157 tools::victauri_verify_state,
1158 tools::victauri_detect_ghost_commands,
1159 tools::victauri_check_ipc_integrity,
1160 ])
1161 .build())
1162 }
1163 }
1164}
1165
1166#[cfg(debug_assertions)]
1167fn format_window_event(label: &str, event: &tauri::WindowEvent) -> (String, String) {
1168 match event {
1169 tauri::WindowEvent::Resized(size) => (
1170 format!("window:{label}:resized"),
1171 serde_json::json!({"width": size.width, "height": size.height}).to_string(),
1172 ),
1173 tauri::WindowEvent::Moved(pos) => (
1174 format!("window:{label}:moved"),
1175 serde_json::json!({"x": pos.x, "y": pos.y}).to_string(),
1176 ),
1177 tauri::WindowEvent::CloseRequested { .. } => {
1178 (format!("window:{label}:close-requested"), String::new())
1179 }
1180 tauri::WindowEvent::Destroyed => (format!("window:{label}:destroyed"), String::new()),
1181 tauri::WindowEvent::Focused(focused) => (
1182 format!("window:{label}:focused"),
1183 serde_json::json!({"focused": focused}).to_string(),
1184 ),
1185 tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => (
1186 format!("window:{label}:scale-factor-changed"),
1187 serde_json::json!({"scale_factor": scale_factor}).to_string(),
1188 ),
1189 tauri::WindowEvent::ThemeChanged(theme) => (
1190 format!("window:{label}:theme-changed"),
1191 serde_json::json!({"theme": format!("{theme:?}")}).to_string(),
1192 ),
1193 tauri::WindowEvent::DragDrop(drag_event) => (
1194 format!("window:{label}:drag-drop"),
1195 format!("{drag_event:?}"),
1196 ),
1197 _ => (format!("window:{label}:other"), format!("{event:?}")),
1198 }
1199}
1200
1201#[cfg(debug_assertions)]
1205fn env_truthy(name: &str) -> bool {
1206 std::env::var(name).is_ok_and(|v| {
1207 matches!(
1208 v.trim().to_ascii_lowercase().as_str(),
1209 "1" | "true" | "yes" | "on"
1210 )
1211 })
1212}
1213
1214#[cfg(debug_assertions)]
1223fn emit_security_banner(port: u16) {
1224 tracing::warn!(
1225 "┌─ VICTAURI INTROSPECTION SERVER ACTIVE ─────────────────────────────\n\
1226 │ Listening on http://127.0.0.1:{port} — exposes JS eval, IPC, the\n\
1227 │ filesystem, and SQLite to local clients. DEBUG-ONLY developer tool;\n\
1228 │ it must never reach end users.\n\
1229 │ Seeing this in a shipped/release build means your release profile has\n\
1230 │ `debug-assertions = true`. Turn that off, or hard-disable Victauri\n\
1231 │ with the VICTAURI_DISABLE=1 environment variable.\n\
1232 └────────────────────────────────────────────────────────────────────"
1233 );
1234}
1235
1236fn resolve_db_search_paths(configured: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
1261 resolve_db_search_paths_against(configured, &db_search_anchors())
1262}
1263
1264fn db_search_anchors() -> Vec<std::path::PathBuf> {
1267 use std::path::PathBuf;
1268 let mut anchors: Vec<PathBuf> = Vec::new();
1269 if let Ok(cwd) = std::env::current_dir() {
1270 anchors.push(cwd);
1271 }
1272 if let Ok(exe) = std::env::current_exe() {
1273 let mut dir = exe.parent().map(PathBuf::from);
1274 for _ in 0..8 {
1277 match dir {
1278 Some(d) => {
1279 anchors.push(d.clone());
1280 dir = d.parent().map(PathBuf::from);
1281 }
1282 None => break,
1283 }
1284 }
1285 }
1286 anchors
1287}
1288
1289fn resolve_db_search_paths_against(
1293 configured: &[std::path::PathBuf],
1294 anchors: &[std::path::PathBuf],
1295) -> Vec<std::path::PathBuf> {
1296 let mut out: Vec<std::path::PathBuf> = Vec::new();
1297 for p in configured {
1298 if p.is_absolute() {
1299 out.push(p.canonicalize().unwrap_or_else(|_| p.clone()));
1300 } else {
1301 for a in anchors {
1302 if let Ok(c) = a.join(p).canonicalize()
1303 && c.is_dir()
1304 {
1305 out.push(c);
1306 }
1307 }
1308 }
1309 }
1310 out.sort();
1311 out.dedup();
1312 out
1313}
1314
1315#[must_use]
1323pub fn init<R: Runtime>() -> TauriPlugin<R> {
1324 VictauriBuilder::new()
1325 .build()
1326 .expect("default Victauri configuration is always valid")
1327}
1328
1329#[must_use]
1338pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
1339 VictauriBuilder::new()
1340 .auto_discover()
1341 .build()
1342 .expect("default Victauri configuration is always valid")
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347 use super::*;
1348
1349 #[test]
1350 fn db_search_resolves_relative_path_via_a_non_cwd_anchor() {
1351 use std::path::PathBuf;
1355 let tmp = tempfile::tempdir().unwrap();
1356 let project = tmp.path().join("myapp");
1357 let data = project.join("data");
1358 std::fs::create_dir_all(&data).unwrap();
1359 std::fs::write(data.join("app.db"), b"x").unwrap();
1360 let wrong_cwd = tmp.path().join("elsewhere");
1364 std::fs::create_dir_all(&wrong_cwd).unwrap();
1365 let src_tauri = project.join("src-tauri");
1366 std::fs::create_dir_all(&src_tauri).unwrap();
1367
1368 let configured = vec![PathBuf::from("../data")]; let cwd_only =
1371 resolve_db_search_paths_against(&configured, std::slice::from_ref(&wrong_cwd));
1372 assert!(
1373 cwd_only.is_empty(),
1374 "CWD-only should miss the db, got {cwd_only:?}"
1375 );
1376 let multi = resolve_db_search_paths_against(&configured, &[wrong_cwd, src_tauri]);
1378 let want = data.canonicalize().unwrap();
1379 assert!(
1380 multi.contains(&want),
1381 "multi-anchor should resolve ../data to {want:?}, got {multi:?}"
1382 );
1383 }
1384
1385 #[test]
1386 fn db_search_passes_absolute_and_dedups() {
1387 use std::path::PathBuf;
1388 let tmp = tempfile::tempdir().unwrap();
1389 let data = tmp.path().join("data");
1390 std::fs::create_dir_all(&data).unwrap();
1391 let abs = data.canonicalize().unwrap();
1392 let out = resolve_db_search_paths_against(
1394 &[abs.clone(), PathBuf::from("data")],
1395 &[tmp.path().to_path_buf(), tmp.path().to_path_buf()],
1396 );
1397 assert_eq!(
1398 out.iter().filter(|p| **p == abs).count(),
1399 1,
1400 "must dedupe: {out:?}"
1401 );
1402 }
1403
1404 #[test]
1405 fn builder_default_values() {
1406 let builder = VictauriBuilder::new();
1407 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
1408 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
1409 assert!(builder.auth_token.is_none());
1410 assert!(!builder.auth_explicitly_enabled);
1411 assert!(!builder.auth_explicitly_disabled);
1412 let resolved = builder.resolve_auth_token();
1413 assert!(
1414 resolved.is_some(),
1415 "auth should be enabled by default (auto-generated token)"
1416 );
1417 assert_eq!(
1418 resolved.unwrap().len(),
1419 36,
1420 "auto-generated token should be UUID v4"
1421 );
1422 assert!(builder.disabled_tools.is_empty());
1423 assert!(builder.command_allowlist.is_none());
1424 assert!(builder.command_blocklist.is_empty());
1425 assert!(!builder.redaction_enabled);
1426 assert!(!builder.strict_privacy);
1427 }
1428
1429 #[test]
1430 fn builder_port_override() {
1431 let builder = VictauriBuilder::new().port(9090);
1432 assert_eq!(builder.resolve_port(), 9090);
1433 }
1434
1435 #[test]
1436 #[allow(unsafe_code)]
1437 fn builder_default_port() {
1438 let builder = VictauriBuilder::new();
1439 unsafe { std::env::remove_var("VICTAURI_PORT") };
1441 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
1442 }
1443
1444 #[test]
1445 fn builder_auth_token_explicit() {
1446 let builder = VictauriBuilder::new().auth_token("my-secret");
1447 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
1448 }
1449
1450 #[cfg(debug_assertions)]
1454 #[test]
1455 #[allow(unsafe_code)]
1456 fn env_truthy_recognizes_kill_switch_values() {
1457 let key = "VICTAURI_TEST_KILL_SWITCH";
1459 unsafe { std::env::remove_var(key) };
1461 assert!(!env_truthy(key), "unset should be false");
1462
1463 for v in ["1", "true", "TRUE", " yes ", "On"] {
1464 unsafe { std::env::set_var(key, v) };
1466 assert!(env_truthy(key), "{v:?} should be truthy");
1467 }
1468 for v in ["0", "false", "", "nope"] {
1469 unsafe { std::env::set_var(key, v) };
1471 assert!(!env_truthy(key), "{v:?} should be falsy");
1472 }
1473 unsafe { std::env::remove_var(key) };
1475 }
1476
1477 #[test]
1478 fn builder_auth_enabled() {
1479 let builder = VictauriBuilder::new().auth_enabled();
1480 assert!(builder.auth_explicitly_enabled);
1481 let token = builder.resolve_auth_token().unwrap();
1482 assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
1483 }
1484
1485 #[test]
1486 fn builder_auth_generate_token() {
1487 let builder = VictauriBuilder::new().generate_auth_token();
1488 let token = builder.resolve_auth_token().unwrap();
1489 assert_eq!(token.len(), 36);
1490 }
1491
1492 #[test]
1493 fn builder_auth_disabled_suppresses_default_token() {
1494 let builder = VictauriBuilder::new().auth_disabled();
1495 assert!(
1496 builder.resolve_auth_token().is_none(),
1497 "auth_disabled must suppress the default auto-generated token (auth is ON by default)"
1498 );
1499 }
1500
1501 #[test]
1502 fn builder_auth_disabled_returns_none() {
1503 let builder = VictauriBuilder::new().auth_disabled();
1504 assert!(
1505 builder.resolve_auth_token().is_none(),
1506 "auth_disabled should suppress auto-generated token"
1507 );
1508 }
1509
1510 #[test]
1511 fn builder_auth_disabled_overrides_explicit_token() {
1512 let builder = VictauriBuilder::new()
1513 .auth_token("my-secret")
1514 .auth_disabled();
1515 assert!(
1516 builder.resolve_auth_token().is_none(),
1517 "auth_disabled should override explicit token"
1518 );
1519 }
1520
1521 #[test]
1522 fn builder_empty_explicit_token_does_not_disable_auth() {
1523 for blank in ["", " ", "\t\n"] {
1526 let resolved = VictauriBuilder::new()
1527 .auth_token(blank)
1528 .resolve_auth_token();
1529 assert!(
1530 resolved.as_deref().is_some_and(|t| !t.trim().is_empty()),
1531 "empty explicit token {blank:?} must resolve to a generated token, not no-auth"
1532 );
1533 }
1534 }
1535
1536 #[test]
1537 fn builder_capacities() {
1538 let builder = VictauriBuilder::new()
1539 .event_capacity(500)
1540 .recorder_capacity(2000);
1541 assert_eq!(builder.event_capacity, 500);
1542 assert_eq!(builder.recorder_capacity, 2000);
1543 }
1544
1545 #[test]
1546 fn builder_disable_tools() {
1547 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
1548 assert_eq!(builder.disabled_tools.len(), 2);
1549 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
1550 }
1551
1552 #[test]
1553 fn builder_command_allowlist() {
1554 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
1555 assert!(builder.command_allowlist.is_some());
1556 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
1557 }
1558
1559 #[test]
1560 fn builder_command_blocklist() {
1561 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
1562 assert_eq!(builder.command_blocklist.len(), 1);
1563 }
1564
1565 #[test]
1566 fn builder_redaction() {
1567 let builder = VictauriBuilder::new()
1568 .add_redaction_pattern(r"SECRET_\w+")
1569 .enable_redaction();
1570 assert!(builder.redaction_enabled);
1571 assert_eq!(builder.redaction_patterns.len(), 1);
1572 }
1573
1574 #[test]
1575 fn builder_strict_privacy_config() {
1576 let builder = VictauriBuilder::new().strict_privacy_mode();
1577 let config = builder.build_privacy_config();
1578 assert!(config.redaction_enabled);
1579 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
1580 assert!(!config.is_tool_enabled("eval_js"));
1581 assert!(!config.is_tool_enabled("screenshot"));
1582 assert!(!config.is_tool_enabled("interact"));
1583 assert!(config.is_tool_enabled("dom_snapshot"));
1584 }
1585
1586 #[test]
1587 fn builder_normal_privacy_config() {
1588 let builder = VictauriBuilder::new()
1589 .command_blocklist(&["secret_cmd"])
1590 .disable_tools(&["eval_js"]);
1591 let config = builder.build_privacy_config();
1592 assert!(config.command_blocklist.contains("secret_cmd"));
1593 assert!(!config.is_tool_enabled("eval_js"));
1594 assert!(!config.redaction_enabled);
1595 }
1596
1597 #[test]
1598 fn builder_strict_with_extra_blocklist() {
1599 let builder = VictauriBuilder::new()
1600 .strict_privacy_mode()
1601 .command_blocklist(&["extra_dangerous"]);
1602 let config = builder.build_privacy_config();
1603 assert!(config.command_blocklist.contains("extra_dangerous"));
1604 assert!(!config.is_tool_enabled("eval_js"));
1605 }
1606
1607 #[test]
1608 fn builder_test_profile() {
1609 let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
1610 let config = builder.build_privacy_config();
1611 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
1612 assert!(config.redaction_enabled);
1613 assert!(config.is_tool_enabled("interact"));
1614 assert!(config.is_tool_enabled("fill"));
1615 assert!(config.is_tool_enabled("recording"));
1616 assert!(!config.is_tool_enabled("eval_js"));
1617 assert!(!config.is_tool_enabled("screenshot"));
1618 assert!(!config.is_tool_enabled("navigate.go_to"));
1621 }
1622
1623 #[test]
1624 fn builder_profile_with_extra_disables() {
1625 let builder = VictauriBuilder::new()
1626 .privacy_profile(crate::privacy::PrivacyProfile::Test)
1627 .disable_tools(&["interact"]);
1628 let config = builder.build_privacy_config();
1629 assert!(!config.is_tool_enabled("interact"));
1630 assert!(config.is_tool_enabled("fill"));
1631 }
1632
1633 #[test]
1634 fn builder_bridge_capacities() {
1635 let builder = VictauriBuilder::new()
1636 .console_log_capacity(5000)
1637 .network_log_capacity(2000)
1638 .navigation_log_capacity(500);
1639 assert_eq!(builder.bridge_capacities.console_logs, 5000);
1640 assert_eq!(builder.bridge_capacities.network_log, 2000);
1641 assert_eq!(builder.bridge_capacities.navigation_log, 500);
1642 assert_eq!(builder.bridge_capacities.mutation_log, 500);
1643 assert_eq!(builder.bridge_capacities.dialog_log, 100);
1644 }
1645
1646 #[test]
1647 fn builder_on_ready_sets_callback() {
1648 let builder = VictauriBuilder::new().on_ready(|_port| {});
1649 assert!(builder.on_ready.is_some());
1650 }
1651
1652 #[test]
1653 fn builder_file_navigation_disabled_by_default() {
1654 let builder = VictauriBuilder::new();
1655 assert!(
1656 !builder.allow_file_navigation,
1657 "file navigation should be disabled by default"
1658 );
1659 }
1660
1661 #[test]
1662 fn builder_allow_file_navigation() {
1663 let builder = VictauriBuilder::new().allow_file_navigation();
1664 assert!(builder.allow_file_navigation);
1665 }
1666
1667 #[test]
1668 fn builder_listen_events() {
1669 let builder =
1670 VictauriBuilder::new().listen_events(&["notification-added", "settings-changed"]);
1671 assert_eq!(builder.listen_events.len(), 2);
1672 assert!(
1673 builder
1674 .listen_events
1675 .contains(&"notification-added".to_string())
1676 );
1677 assert!(
1678 builder
1679 .listen_events
1680 .contains(&"settings-changed".to_string())
1681 );
1682 }
1683
1684 #[test]
1685 fn builder_listen_events_empty_by_default() {
1686 let builder = VictauriBuilder::new();
1687 assert!(builder.listen_events.is_empty());
1688 }
1689
1690 #[test]
1691 fn init_script_contains_custom_capacities() {
1692 let caps = js_bridge::BridgeCapacities {
1693 console_logs: 3000,
1694 mutation_log: 750,
1695 network_log: 5000,
1696 navigation_log: 400,
1697 dialog_log: 250,
1698 long_tasks: 200,
1699 };
1700 let script = js_bridge::init_script(&caps);
1701 assert!(script.contains("CAP_CONSOLE = 3000"));
1702 assert!(script.contains("CAP_MUTATION = 750"));
1703 assert!(script.contains("CAP_NETWORK = 5000"));
1704 assert!(script.contains("CAP_NAVIGATION = 400"));
1705 assert!(script.contains("CAP_DIALOG = 250"));
1706 assert!(script.contains("CAP_LONG_TASKS = 200"));
1707 }
1708
1709 #[test]
1710 fn init_script_default_contains_standard_capacities() {
1711 let caps = js_bridge::BridgeCapacities::default();
1712 let script = js_bridge::init_script(&caps);
1713 assert!(script.contains("CAP_CONSOLE = 1000"));
1714 assert!(script.contains("CAP_NETWORK = 1000"));
1715 assert!(script.contains("window.__VICTAURI__"));
1716 }
1717
1718 #[test]
1719 fn builder_validates_defaults() {
1720 let builder = VictauriBuilder::new();
1721 assert!(builder.validate().is_ok());
1722 }
1723
1724 #[test]
1725 fn builder_rejects_zero_port() {
1726 let builder = VictauriBuilder::new().port(0);
1727 let err = builder.validate().unwrap_err();
1728 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
1729 }
1730
1731 #[test]
1732 fn builder_rejects_zero_event_capacity() {
1733 let builder = VictauriBuilder::new().event_capacity(0);
1734 let err = builder.validate().unwrap_err();
1735 assert!(matches!(
1736 err,
1737 BuilderError::InvalidEventCapacity { capacity: 0, .. }
1738 ));
1739 }
1740
1741 #[test]
1742 fn builder_rejects_excessive_event_capacity() {
1743 let builder = VictauriBuilder::new().event_capacity(2_000_000);
1744 assert!(builder.validate().is_err());
1745 }
1746
1747 #[test]
1748 fn builder_rejects_zero_recorder_capacity() {
1749 let builder = VictauriBuilder::new().recorder_capacity(0);
1750 assert!(builder.validate().is_err());
1751 }
1752
1753 #[test]
1754 fn builder_rejects_zero_eval_timeout() {
1755 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
1756 assert!(builder.validate().is_err());
1757 }
1758
1759 #[test]
1760 fn builder_rejects_excessive_eval_timeout() {
1761 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1762 assert!(builder.validate().is_err());
1763 }
1764
1765 #[test]
1766 fn builder_accepts_edge_values() {
1767 let builder = VictauriBuilder::new()
1768 .port(1)
1769 .event_capacity(1)
1770 .recorder_capacity(1)
1771 .eval_timeout(std::time::Duration::from_secs(1));
1772 assert!(builder.validate().is_ok());
1773
1774 let builder = VictauriBuilder::new()
1775 .port(65535)
1776 .event_capacity(MAX_EVENT_CAPACITY)
1777 .recorder_capacity(MAX_RECORDER_CAPACITY)
1778 .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1779 assert!(builder.validate().is_ok());
1780 }
1781}