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}
197
198impl PendingSlot {
199 pub(crate) fn insert(
202 map: &PendingCallbacks,
203 locked: &mut HashMap<String, oneshot::Sender<String>>,
204 id: String,
205 tx: oneshot::Sender<String>,
206 ) -> Self {
207 locked.insert(id.clone(), tx);
208 Self {
209 map: Arc::clone(map),
210 id,
211 }
212 }
213
214 pub(crate) fn id(&self) -> &str {
216 &self.id
217 }
218
219 pub(crate) fn bind_window(&self, label: Option<&str>) {
224 let target = label.map_or(EvalTarget::DefaultWindow, |l| {
225 EvalTarget::Window(l.to_string())
226 });
227 eval_targets().insert(self.id.clone(), target);
228 }
229}
230
231#[derive(Clone, Debug, PartialEq, Eq)]
233enum EvalTarget {
234 Window(String),
236 DefaultWindow,
238}
239
240static EVAL_TARGETS: std::sync::LazyLock<std::sync::Mutex<HashMap<String, EvalTarget>>> =
243 std::sync::LazyLock::new(Default::default);
244
245fn eval_targets() -> std::sync::MutexGuard<'static, HashMap<String, EvalTarget>> {
246 EVAL_TARGETS
247 .lock()
248 .unwrap_or_else(std::sync::PoisonError::into_inner)
249}
250
251pub(crate) fn eval_result_accepted(
256 id: &str,
257 caller: &str,
258 default_label: impl FnOnce() -> Option<String>,
259) -> bool {
260 let target = eval_targets().get(id).cloned();
261 match target {
262 Some(EvalTarget::Window(label)) => label == caller,
263 Some(EvalTarget::DefaultWindow) => default_label().as_deref() == Some(caller),
264 None => false,
265 }
266}
267
268impl Drop for PendingSlot {
269 fn drop(&mut self) {
270 eval_targets().remove(&self.id);
271 if let Ok(mut map) = self.map.try_lock() {
274 map.remove(&self.id);
275 return;
276 }
277 let map = Arc::clone(&self.map);
278 let id = std::mem::take(&mut self.id);
279 match tokio::runtime::Handle::try_current() {
280 Ok(rt) => {
281 rt.spawn(async move {
282 map.lock().await.remove(&id);
283 });
284 }
285 Err(_) => {
286 map.blocking_lock().remove(&id);
287 }
288 }
289 }
290}
291
292#[non_exhaustive]
298pub struct VictauriState {
299 pub event_log: EventLog,
301 pub registry: CommandRegistry,
303 pub port: AtomicU16,
305 pub pending_evals: PendingCallbacks,
307 pub recorder: EventRecorder,
309 pub privacy: privacy::PrivacyConfig,
311 pub eval_timeout: std::time::Duration,
313 pub shutdown_tx: watch::Sender<bool>,
315 pub started_at: std::time::Instant,
317 pub tool_invocations: AtomicU64,
319 pub allow_file_navigation: bool,
323 pub command_timings: introspection::CommandTimings,
325 pub fault_registry: introspection::FaultRegistry,
327 pub contract_store: introspection::ContractStore,
329 pub startup_timeline: introspection::StartupTimeline,
331 pub event_bus: introspection::EventBusMonitor,
333 pub task_tracker: introspection::TaskTracker,
335 pub bridge_ready: AtomicBool,
337 pub bridge_notify: tokio::sync::Notify,
339 pub screencast: Arc<screencast::Screencast>,
341 pub db_search_paths: Vec<std::path::PathBuf>,
348 pub probes: introspection::AppStateProbes,
351 pub(crate) drain_watermarks: introspection::DrainWatermarks,
353 pub(crate) page_loads: introspection::PageLoads,
355}
356
357impl VictauriState {
358 #[doc(hidden)]
369 #[must_use]
370 pub fn for_tests() -> Self {
371 Self {
372 event_log: EventLog::new(1000),
373 registry: CommandRegistry::new(),
374 port: AtomicU16::new(0),
375 pending_evals: Arc::new(Mutex::new(HashMap::new())),
376 recorder: EventRecorder::new(1000),
377 privacy: privacy::PrivacyConfig::default(),
378 eval_timeout: std::time::Duration::from_secs(30),
379 shutdown_tx: watch::channel(false).0,
380 started_at: std::time::Instant::now(),
381 tool_invocations: AtomicU64::new(0),
382 allow_file_navigation: false,
383 command_timings: introspection::CommandTimings::new(),
384 fault_registry: introspection::FaultRegistry::new(),
385 contract_store: introspection::ContractStore::new(),
386 startup_timeline: introspection::StartupTimeline::new(),
387 event_bus: introspection::EventBusMonitor::default(),
388 task_tracker: introspection::TaskTracker::new(),
389 bridge_ready: AtomicBool::new(true),
390 bridge_notify: tokio::sync::Notify::new(),
391 screencast: Arc::new(screencast::Screencast::default()),
392 db_search_paths: Vec::new(),
393 probes: introspection::AppStateProbes::default(),
394 drain_watermarks: introspection::DrainWatermarks::default(),
395 page_loads: introspection::PageLoads::default(),
396 }
397 }
398}
399
400pub struct VictauriBuilder {
414 port: Option<u16>,
415 event_capacity: usize,
416 recorder_capacity: usize,
417 eval_timeout: std::time::Duration,
418 auth_token: Option<String>,
419 auth_explicitly_enabled: bool,
420 auth_explicitly_disabled: bool,
421 disabled_tools: Vec<String>,
422 command_allowlist: Option<Vec<String>>,
423 command_blocklist: Vec<String>,
424 storage_key_blocklist: Vec<String>,
425 redaction_patterns: Vec<String>,
426 redaction_enabled: bool,
427 strict_privacy: bool,
428 privacy_profile: Option<privacy::PrivacyProfile>,
429 bridge_capacities: js_bridge::BridgeCapacities,
430 on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
431 commands: Vec<victauri_core::CommandInfo>,
432 allow_file_navigation: bool,
433 listen_events: Vec<String>,
434 db_search_paths: Vec<std::path::PathBuf>,
435 probes: Vec<(String, std::sync::Arc<introspection::ProbeFn>)>,
436}
437
438impl Default for VictauriBuilder {
439 fn default() -> Self {
440 Self {
441 port: None,
442 event_capacity: DEFAULT_EVENT_CAPACITY,
443 recorder_capacity: DEFAULT_RECORDER_CAPACITY,
444 eval_timeout: DEFAULT_EVAL_TIMEOUT,
445 auth_token: None,
446 auth_explicitly_enabled: false,
447 auth_explicitly_disabled: false,
448 disabled_tools: Vec::new(),
449 command_allowlist: None,
450 command_blocklist: Vec::new(),
451 storage_key_blocklist: Vec::new(),
452 redaction_patterns: Vec::new(),
453 redaction_enabled: false,
454 strict_privacy: false,
455 privacy_profile: None,
456 bridge_capacities: js_bridge::BridgeCapacities::default(),
457 on_ready: None,
458 commands: Vec::new(),
459 allow_file_navigation: false,
460 listen_events: Vec::new(),
461 db_search_paths: Vec::new(),
462 probes: Vec::new(),
463 }
464 }
465}
466
467impl VictauriBuilder {
468 #[must_use]
470 pub fn new() -> Self {
471 Self::default()
472 }
473
474 #[must_use]
476 pub fn port(mut self, port: u16) -> Self {
477 self.port = Some(port);
478 self
479 }
480
481 #[must_use]
483 pub fn event_capacity(mut self, capacity: usize) -> Self {
484 self.event_capacity = capacity;
485 self
486 }
487
488 #[must_use]
490 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
491 self.recorder_capacity = capacity;
492 self
493 }
494
495 #[must_use]
497 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
498 self.eval_timeout = timeout;
499 self
500 }
501
502 #[must_use]
506 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
507 self.auth_token = Some(token.into());
508 self
509 }
510
511 #[must_use]
521 pub fn auth_enabled(mut self) -> Self {
522 self.auth_explicitly_enabled = true;
523 self
524 }
525
526 #[must_use]
528 pub fn generate_auth_token(mut self) -> Self {
529 self.auth_explicitly_enabled = true;
530 self
531 }
532
533 #[must_use]
543 pub fn auth_disabled(mut self) -> Self {
544 self.auth_explicitly_disabled = true;
545 self
546 }
547
548 #[must_use]
554 pub fn disable_tools(mut self, tools: &[&str]) -> Self {
555 for unknown in mcp::authz::unknown_names(tools.iter().copied()) {
556 tracing::warn!(
557 name = unknown,
558 "disable_tools: {unknown:?} is not a Victauri tool, `tool.action`, or capability \
559 name, so it disables nothing (check the spelling against the tool list)"
560 );
561 }
562 self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
563 self
564 }
565
566 #[must_use]
568 pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
569 self.command_allowlist = Some(
570 commands
571 .iter()
572 .map(std::string::ToString::to_string)
573 .collect(),
574 );
575 self
576 }
577
578 #[must_use]
580 pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
581 self.command_blocklist = commands
582 .iter()
583 .map(std::string::ToString::to_string)
584 .collect();
585 self
586 }
587
588 #[must_use]
593 pub fn storage_key_blocklist(mut self, keys: &[&str]) -> Self {
594 self.storage_key_blocklist = keys.iter().map(std::string::ToString::to_string).collect();
595 self
596 }
597
598 #[must_use]
600 pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
601 self.redaction_patterns.push(pattern.into());
602 self
603 }
604
605 #[must_use]
607 pub fn enable_redaction(mut self) -> Self {
608 self.redaction_enabled = true;
609 self
610 }
611
612 #[must_use]
619 pub fn strict_privacy_mode(mut self) -> Self {
620 self.strict_privacy = true;
621 self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
622 self
623 }
624
625 #[must_use]
634 pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
635 self.privacy_profile = Some(profile);
636 if matches!(
637 profile,
638 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
639 ) {
640 self.redaction_enabled = true;
641 }
642 self
643 }
644
645 #[must_use]
647 pub fn console_log_capacity(mut self, capacity: usize) -> Self {
648 self.bridge_capacities.console_logs = capacity;
649 self
650 }
651
652 #[must_use]
654 pub fn network_log_capacity(mut self, capacity: usize) -> Self {
655 self.bridge_capacities.network_log = capacity;
656 self
657 }
658
659 #[must_use]
661 pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
662 self.bridge_capacities.navigation_log = capacity;
663 self
664 }
665
666 #[must_use]
681 pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
682 self.commands = schemas.to_vec();
683 self
684 }
685
686 #[must_use]
702 pub fn register_command_names(mut self, names: &[&str]) -> Self {
703 self.commands
704 .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
705 self
706 }
707
708 #[must_use]
724 pub fn auto_discover(mut self) -> Self {
725 self.commands
726 .extend(victauri_core::auto_discovered_commands());
727 self
728 }
729
730 #[must_use]
741 pub fn listen_events(mut self, events: &[&str]) -> Self {
742 self.listen_events = events
743 .iter()
744 .map(std::string::ToString::to_string)
745 .collect();
746 self
747 }
748
749 #[must_use]
758 pub fn allow_file_navigation(mut self) -> Self {
759 self.allow_file_navigation = true;
760 self
761 }
762
763 #[must_use]
776 pub fn db_search_paths<I, P>(mut self, paths: I) -> Self
777 where
778 I: IntoIterator<Item = P>,
779 P: Into<std::path::PathBuf>,
780 {
781 self.db_search_paths
782 .extend(paths.into_iter().map(Into::into));
783 self
784 }
785
786 #[must_use]
820 pub fn probe<F>(mut self, name: impl Into<String>, probe: F) -> Self
821 where
822 F: Fn() -> serde_json::Value + Send + Sync + 'static,
823 {
824 self.probes.push((name.into(), std::sync::Arc::new(probe)));
825 self
826 }
827
828 #[must_use]
841 pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
842 self.on_ready = Some(Box::new(f));
843 self
844 }
845
846 fn resolve_port(&self) -> u16 {
847 self.port
848 .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
849 .unwrap_or(DEFAULT_PORT)
850 }
851
852 fn resolve_auth_token(&self) -> Option<String> {
853 if self.auth_explicitly_disabled {
854 return None;
855 }
856 if let Some(ref token) = self.auth_token
862 && !token.trim().is_empty()
863 {
864 return Some(token.clone());
865 }
866 if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN")
867 && !token.trim().is_empty()
868 {
869 return Some(token);
870 }
871 Some(auth::generate_token())
872 }
873
874 fn resolve_eval_timeout(&self) -> std::time::Duration {
875 std::env::var("VICTAURI_EVAL_TIMEOUT")
876 .ok()
877 .and_then(|s| s.parse::<u64>().ok())
878 .map_or(self.eval_timeout, std::time::Duration::from_secs)
879 }
880
881 fn build_privacy_config(&self) -> privacy::PrivacyConfig {
882 let profile = self
883 .privacy_profile
884 .unwrap_or(privacy::PrivacyProfile::FullControl);
885
886 let redaction_enabled = self.redaction_enabled
887 || self.strict_privacy
888 || matches!(
889 profile,
890 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
891 );
892
893 privacy::PrivacyConfig {
894 profile,
895 command_allowlist: self
896 .command_allowlist
897 .as_ref()
898 .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
899 command_blocklist: self.command_blocklist.iter().cloned().collect(),
900 disabled_tools: self.disabled_tools.iter().cloned().collect(),
901 storage_key_blocklist: self.storage_key_blocklist.iter().cloned().collect(),
902 redactor: redaction::Redactor::new(&self.redaction_patterns),
903 redaction_enabled,
904 }
905 }
906
907 fn validate(&self) -> Result<(), BuilderError> {
908 let port = self.resolve_port();
909 if port == 0 {
910 return Err(BuilderError::InvalidPort {
911 port,
912 reason: "port 0 is reserved".to_string(),
913 });
914 }
915
916 if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
917 return Err(BuilderError::InvalidEventCapacity {
918 capacity: self.event_capacity,
919 reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
920 });
921 }
922
923 if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
924 return Err(BuilderError::InvalidRecorderCapacity {
925 capacity: self.recorder_capacity,
926 reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
927 });
928 }
929
930 let timeout = self.resolve_eval_timeout();
931 if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
932 return Err(BuilderError::InvalidEvalTimeout {
933 timeout_secs: timeout.as_secs(),
934 reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
935 });
936 }
937
938 Ok(())
939 }
940
941 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
951 #[cfg(not(debug_assertions))]
952 {
953 Ok(Builder::new("victauri").build())
954 }
955
956 #[cfg(debug_assertions)]
957 {
958 if env_truthy("VICTAURI_DISABLE") {
962 tracing::info!("Victauri disabled via VICTAURI_DISABLE — returning no-op plugin");
963 return Ok(Builder::new("victauri").build());
964 }
965
966 self.validate()?;
967
968 let port = self.resolve_port();
969 let event_capacity = self.event_capacity;
970 let recorder_capacity = self.recorder_capacity;
971 let eval_timeout = self.resolve_eval_timeout();
972 let auth_token = self.resolve_auth_token();
973 let privacy_config = self.build_privacy_config();
974 let allow_file_navigation = self.allow_file_navigation;
975 let db_search_paths = self.db_search_paths;
976 let on_ready = self.on_ready;
977 let commands = self.commands;
978 let listen_events = self.listen_events;
979 let probes = self.probes;
980 let js_init = js_bridge::init_script(&self.bridge_capacities);
981
982 let builder = Builder::new("victauri")
983 .setup(move |app, _api| {
984 match bridge::adopt_main_thread(app) {
990 Ok(bridge::MainThreadAdoption::Inline) => {}
991 Ok(bridge::MainThreadAdoption::Posted) => tracing::warn!(
992 "Victauri: plugin setup ran off the main thread (registered at \
993 runtime from a background thread?); the main-thread state is being \
994 installed on the main thread — webview/window tools called before \
995 it is will fail with \"not installed\""
996 ),
997 Err(e) => tracing::error!(
998 "Victauri: could not reach the main thread to install its state \
999 ({e}); webview and window tools will fail with \"not installed\""
1000 ),
1001 }
1002 let startup_timeline = introspection::StartupTimeline::new();
1003 let event_log = EventLog::new(event_capacity);
1004 startup_timeline.mark("event_log_created");
1005 let registry = CommandRegistry::new();
1006 startup_timeline.mark("registry_created");
1007 let (shutdown_tx, shutdown_rx) = watch::channel(false);
1008
1009 let db_search_paths = resolve_db_search_paths(&db_search_paths);
1014
1015 let state = Arc::new(VictauriState {
1016 event_log,
1017 registry,
1018 port: AtomicU16::new(port),
1019 pending_evals: Arc::new(Mutex::new(HashMap::new())),
1020 recorder: EventRecorder::new(recorder_capacity),
1021 privacy: privacy_config,
1022 eval_timeout,
1023 shutdown_tx,
1024 started_at: std::time::Instant::now(),
1025 tool_invocations: AtomicU64::new(0),
1026 allow_file_navigation,
1027 command_timings: introspection::CommandTimings::new(),
1028 fault_registry: introspection::FaultRegistry::new(),
1029 contract_store: introspection::ContractStore::new(),
1030 startup_timeline,
1031 event_bus: introspection::EventBusMonitor::default(),
1032 task_tracker: introspection::TaskTracker::new(),
1033 bridge_ready: AtomicBool::new(false),
1034 bridge_notify: tokio::sync::Notify::new(),
1035 screencast: Arc::new(screencast::Screencast::default()),
1036 db_search_paths,
1037 probes: introspection::AppStateProbes::default(),
1038 drain_watermarks: introspection::DrainWatermarks::default(),
1039 page_loads: introspection::PageLoads::default(),
1040 });
1041 state.startup_timeline.mark("state_created");
1042
1043 for (name, probe) in probes {
1044 state.probes.register(name, probe);
1045 }
1046
1047 app.manage(state.clone());
1048
1049 for cmd in commands {
1050 state.registry.register(cmd);
1051 }
1052 state.startup_timeline.mark("commands_registered");
1053
1054 for event_name in &listen_events {
1056 let bus = state.event_bus.clone();
1057 let name = event_name.clone();
1058 app.listen_any(event_name.clone(), move |event| {
1059 let payload =
1060 serde_json::from_str::<serde_json::Value>(event.payload())
1061 .map_or_else(
1062 |_| event.payload().to_string(),
1063 |v| v.to_string(),
1064 );
1065 bus.push(introspection::CapturedTauriEvent {
1066 name: name.clone(),
1067 payload,
1068 timestamp: chrono::Utc::now()
1069 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1070 });
1071 });
1072 }
1073 state
1074 .startup_timeline
1075 .mark("event_bus_listeners_registered");
1076
1077 if auth_token.is_some() {
1078 tracing::info!(
1081 "Victauri MCP server auth enabled — clients read the token from \
1082 the discovery directory"
1083 );
1084 } else {
1085 tracing::warn!(
1086 "Victauri MCP server running WITHOUT auth — any localhost process can \
1087 access all tools. Use VictauriBuilder::auth_enabled() or set \
1088 VICTAURI_AUTH_TOKEN for shared/CI environments."
1089 );
1090 }
1091
1092 state.startup_timeline.mark("server_spawning");
1093 let app_handle = app.clone();
1094 let ready_state = state.clone();
1095 let server_finished = state.task_tracker.track_guarded("mcp_server");
1096 let (bound_tx, bound_rx) = tokio::sync::oneshot::channel();
1097 tauri::async_runtime::spawn(async move {
1098 let _finished = server_finished;
1099 match mcp::start_server_reporting_port(
1100 app_handle,
1101 state,
1102 port,
1103 auth_token,
1104 shutdown_rx,
1105 Some(bound_tx),
1106 )
1107 .await
1108 {
1109 Ok(()) => {
1110 tracing::info!("Victauri MCP server stopped");
1111 }
1112 Err(e) => {
1113 tracing::error!("Victauri MCP server failed: {e}");
1114 }
1115 }
1116 });
1117
1118 let ready_finished = ready_state.task_tracker.track_guarded("on_ready_probe");
1123 tauri::async_runtime::spawn(async move {
1124 let _finished = ready_finished;
1125 match tokio::time::timeout(std::time::Duration::from_secs(5), bound_rx)
1126 .await
1127 {
1128 Ok(Ok(Ok(actual_port))) => {
1129 emit_security_banner(actual_port);
1130 if let Some(cb) = on_ready {
1131 cb(actual_port);
1132 }
1133 }
1134 Ok(Ok(Err(e))) => tracing::warn!(
1135 "Victauri: server did not start ({e}); on_ready not called"
1136 ),
1137 Ok(Err(_)) | Err(_) => tracing::warn!(
1138 "Victauri: server did not report a bound port within 5s; \
1139 on_ready not called"
1140 ),
1141 }
1142 });
1143 Ok(())
1144 })
1145 .on_event(|app, event| {
1146 let Some(state) = app.try_state::<Arc<VictauriState>>() else {
1147 return;
1148 };
1149 match event {
1150 RunEvent::Exit => {
1151 state.shutdown_tx.send_replace(true);
1155 tracing::info!("Victauri shutdown signal sent");
1156 }
1157 RunEvent::ExitRequested { .. } => {
1158 state.event_bus.push(introspection::CapturedTauriEvent {
1159 name: "tauri://exit-requested".to_string(),
1160 payload: String::new(),
1161 timestamp: chrono::Utc::now()
1162 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1163 });
1164 }
1165 RunEvent::WindowEvent {
1166 label,
1167 event: win_event,
1168 ..
1169 } => {
1170 let (name, payload) = format_window_event(label, win_event);
1171 state.event_bus.push(introspection::CapturedTauriEvent {
1172 name,
1173 payload,
1174 timestamp: chrono::Utc::now()
1175 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1176 });
1177 }
1178 _ => {}
1179 }
1180 });
1181 #[cfg(feature = "tauri-v3")]
1185 let builder = builder.initialization_script(js_init);
1186 #[cfg(not(feature = "tauri-v3"))]
1187 let builder = builder.js_init_script(js_init);
1188
1189 Ok(builder
1190 .invoke_handler(tauri::generate_handler![
1191 tools::victauri_eval_js,
1192 tools::victauri_eval_callback,
1193 tools::victauri_get_window_state,
1194 tools::victauri_list_windows,
1195 tools::victauri_get_ipc_log,
1196 tools::victauri_get_registry,
1197 tools::victauri_get_memory_stats,
1198 tools::victauri_dom_snapshot,
1199 tools::victauri_verify_state,
1200 tools::victauri_detect_ghost_commands,
1201 tools::victauri_check_ipc_integrity,
1202 ])
1203 .build())
1204 }
1205 }
1206}
1207
1208#[cfg(debug_assertions)]
1209fn format_window_event(label: &str, event: &tauri::WindowEvent) -> (String, String) {
1210 match event {
1211 tauri::WindowEvent::Resized(size) => (
1212 format!("window:{label}:resized"),
1213 serde_json::json!({"width": size.width, "height": size.height}).to_string(),
1214 ),
1215 tauri::WindowEvent::Moved(pos) => (
1216 format!("window:{label}:moved"),
1217 serde_json::json!({"x": pos.x, "y": pos.y}).to_string(),
1218 ),
1219 tauri::WindowEvent::CloseRequested { .. } => {
1220 (format!("window:{label}:close-requested"), String::new())
1221 }
1222 tauri::WindowEvent::Destroyed => (format!("window:{label}:destroyed"), String::new()),
1223 tauri::WindowEvent::Focused(focused) => (
1224 format!("window:{label}:focused"),
1225 serde_json::json!({"focused": focused}).to_string(),
1226 ),
1227 tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => (
1228 format!("window:{label}:scale-factor-changed"),
1229 serde_json::json!({"scale_factor": scale_factor}).to_string(),
1230 ),
1231 tauri::WindowEvent::ThemeChanged(theme) => (
1232 format!("window:{label}:theme-changed"),
1233 serde_json::json!({"theme": format!("{theme:?}")}).to_string(),
1234 ),
1235 tauri::WindowEvent::DragDrop(drag_event) => (
1236 format!("window:{label}:drag-drop"),
1237 format!("{drag_event:?}"),
1238 ),
1239 _ => (format!("window:{label}:other"), format!("{event:?}")),
1240 }
1241}
1242
1243#[cfg(debug_assertions)]
1247fn env_truthy(name: &str) -> bool {
1248 std::env::var(name).is_ok_and(|v| {
1249 matches!(
1250 v.trim().to_ascii_lowercase().as_str(),
1251 "1" | "true" | "yes" | "on"
1252 )
1253 })
1254}
1255
1256#[cfg(debug_assertions)]
1265fn emit_security_banner(port: u16) {
1266 tracing::warn!(
1267 "┌─ VICTAURI INTROSPECTION SERVER ACTIVE ─────────────────────────────\n\
1268 │ Listening on http://127.0.0.1:{port} — exposes JS eval, IPC, the\n\
1269 │ filesystem, and SQLite to local clients. DEBUG-ONLY developer tool;\n\
1270 │ it must never reach end users.\n\
1271 │ Seeing this in a shipped/release build means your release profile has\n\
1272 │ `debug-assertions = true`. Turn that off, or hard-disable Victauri\n\
1273 │ with the VICTAURI_DISABLE=1 environment variable.\n\
1274 └────────────────────────────────────────────────────────────────────"
1275 );
1276}
1277
1278fn resolve_db_search_paths(configured: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
1289 resolve_db_search_paths_against(configured, &db_search_anchors())
1290}
1291
1292fn db_search_anchors() -> Vec<std::path::PathBuf> {
1295 use std::path::PathBuf;
1296 let mut anchors: Vec<PathBuf> = Vec::new();
1297 if let Ok(cwd) = std::env::current_dir() {
1298 anchors.push(cwd);
1299 }
1300 if let Ok(exe) = std::env::current_exe() {
1301 let mut dir = exe.parent().map(PathBuf::from);
1302 for _ in 0..8 {
1305 match dir {
1306 Some(d) => {
1307 anchors.push(d.clone());
1308 dir = d.parent().map(PathBuf::from);
1309 }
1310 None => break,
1311 }
1312 }
1313 }
1314 anchors
1315}
1316
1317fn resolve_db_search_paths_against(
1321 configured: &[std::path::PathBuf],
1322 anchors: &[std::path::PathBuf],
1323) -> Vec<std::path::PathBuf> {
1324 let mut out: Vec<std::path::PathBuf> = Vec::new();
1325 for p in configured {
1326 if p.is_absolute() {
1327 out.push(p.canonicalize().unwrap_or_else(|_| p.clone()));
1328 } else {
1329 for a in anchors {
1330 if let Ok(c) = a.join(p).canonicalize()
1331 && c.is_dir()
1332 {
1333 out.push(c);
1334 }
1335 }
1336 }
1337 }
1338 out.sort();
1339 out.dedup();
1340 out
1341}
1342
1343#[must_use]
1367pub fn init<R: Runtime>() -> TauriPlugin<R> {
1368 VictauriBuilder::new()
1369 .build()
1370 .expect("default Victauri configuration is always valid")
1371}
1372
1373#[must_use]
1382pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
1383 VictauriBuilder::new()
1384 .auto_discover()
1385 .build()
1386 .expect("default Victauri configuration is always valid")
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391 use super::*;
1392
1393 #[test]
1394 fn db_search_resolves_relative_path_via_a_non_cwd_anchor() {
1395 use std::path::PathBuf;
1399 let tmp = tempfile::tempdir().unwrap();
1400 let project = tmp.path().join("myapp");
1401 let data = project.join("data");
1402 std::fs::create_dir_all(&data).unwrap();
1403 std::fs::write(data.join("app.db"), b"x").unwrap();
1404 let wrong_cwd = tmp.path().join("elsewhere");
1408 std::fs::create_dir_all(&wrong_cwd).unwrap();
1409 let src_tauri = project.join("src-tauri");
1410 std::fs::create_dir_all(&src_tauri).unwrap();
1411
1412 let configured = vec![PathBuf::from("../data")]; let cwd_only =
1415 resolve_db_search_paths_against(&configured, std::slice::from_ref(&wrong_cwd));
1416 assert!(
1417 cwd_only.is_empty(),
1418 "CWD-only should miss the db, got {cwd_only:?}"
1419 );
1420 let multi = resolve_db_search_paths_against(&configured, &[wrong_cwd, src_tauri]);
1422 let want = data.canonicalize().unwrap();
1423 assert!(
1424 multi.contains(&want),
1425 "multi-anchor should resolve ../data to {want:?}, got {multi:?}"
1426 );
1427 }
1428
1429 #[test]
1430 fn db_search_passes_absolute_and_dedups() {
1431 use std::path::PathBuf;
1432 let tmp = tempfile::tempdir().unwrap();
1433 let data = tmp.path().join("data");
1434 std::fs::create_dir_all(&data).unwrap();
1435 let abs = data.canonicalize().unwrap();
1436 let out = resolve_db_search_paths_against(
1438 &[abs.clone(), PathBuf::from("data")],
1439 &[tmp.path().to_path_buf(), tmp.path().to_path_buf()],
1440 );
1441 assert_eq!(
1442 out.iter().filter(|p| **p == abs).count(),
1443 1,
1444 "must dedupe: {out:?}"
1445 );
1446 }
1447
1448 #[test]
1449 fn builder_default_values() {
1450 let builder = VictauriBuilder::new();
1451 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
1452 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
1453 assert!(builder.auth_token.is_none());
1454 assert!(!builder.auth_explicitly_enabled);
1455 assert!(!builder.auth_explicitly_disabled);
1456 let resolved = builder.resolve_auth_token();
1457 assert!(
1458 resolved.is_some(),
1459 "auth should be enabled by default (auto-generated token)"
1460 );
1461 assert_eq!(
1462 resolved.unwrap().len(),
1463 36,
1464 "auto-generated token should be UUID v4"
1465 );
1466 assert!(builder.disabled_tools.is_empty());
1467 assert!(builder.command_allowlist.is_none());
1468 assert!(builder.command_blocklist.is_empty());
1469 assert!(!builder.redaction_enabled);
1470 assert!(!builder.strict_privacy);
1471 }
1472
1473 #[test]
1474 fn builder_port_override() {
1475 let builder = VictauriBuilder::new().port(9090);
1476 assert_eq!(builder.resolve_port(), 9090);
1477 }
1478
1479 #[test]
1480 #[allow(unsafe_code)]
1481 fn builder_default_port() {
1482 let builder = VictauriBuilder::new();
1483 unsafe { std::env::remove_var("VICTAURI_PORT") };
1485 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
1486 }
1487
1488 #[test]
1489 fn builder_auth_token_explicit() {
1490 let builder = VictauriBuilder::new().auth_token("my-secret");
1491 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
1492 }
1493
1494 #[cfg(debug_assertions)]
1498 #[test]
1499 #[allow(unsafe_code)]
1500 fn env_truthy_recognizes_kill_switch_values() {
1501 let key = "VICTAURI_TEST_KILL_SWITCH";
1503 unsafe { std::env::remove_var(key) };
1505 assert!(!env_truthy(key), "unset should be false");
1506
1507 for v in ["1", "true", "TRUE", " yes ", "On"] {
1508 unsafe { std::env::set_var(key, v) };
1510 assert!(env_truthy(key), "{v:?} should be truthy");
1511 }
1512 for v in ["0", "false", "", "nope"] {
1513 unsafe { std::env::set_var(key, v) };
1515 assert!(!env_truthy(key), "{v:?} should be falsy");
1516 }
1517 unsafe { std::env::remove_var(key) };
1519 }
1520
1521 #[test]
1522 fn builder_auth_enabled() {
1523 let builder = VictauriBuilder::new().auth_enabled();
1524 assert!(builder.auth_explicitly_enabled);
1525 let token = builder.resolve_auth_token().unwrap();
1526 assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
1527 }
1528
1529 #[test]
1530 fn builder_auth_generate_token() {
1531 let builder = VictauriBuilder::new().generate_auth_token();
1532 let token = builder.resolve_auth_token().unwrap();
1533 assert_eq!(token.len(), 36);
1534 }
1535
1536 #[test]
1537 fn builder_auth_disabled_suppresses_default_token() {
1538 let builder = VictauriBuilder::new().auth_disabled();
1539 assert!(
1540 builder.resolve_auth_token().is_none(),
1541 "auth_disabled must suppress the default auto-generated token (auth is ON by default)"
1542 );
1543 }
1544
1545 #[test]
1546 fn builder_auth_disabled_returns_none() {
1547 let builder = VictauriBuilder::new().auth_disabled();
1548 assert!(
1549 builder.resolve_auth_token().is_none(),
1550 "auth_disabled should suppress auto-generated token"
1551 );
1552 }
1553
1554 #[test]
1555 fn builder_auth_disabled_overrides_explicit_token() {
1556 let builder = VictauriBuilder::new()
1557 .auth_token("my-secret")
1558 .auth_disabled();
1559 assert!(
1560 builder.resolve_auth_token().is_none(),
1561 "auth_disabled should override explicit token"
1562 );
1563 }
1564
1565 #[test]
1566 fn builder_empty_explicit_token_does_not_disable_auth() {
1567 for blank in ["", " ", "\t\n"] {
1570 let resolved = VictauriBuilder::new()
1571 .auth_token(blank)
1572 .resolve_auth_token();
1573 assert!(
1574 resolved.as_deref().is_some_and(|t| !t.trim().is_empty()),
1575 "empty explicit token {blank:?} must resolve to a generated token, not no-auth"
1576 );
1577 }
1578 }
1579
1580 #[test]
1581 fn builder_capacities() {
1582 let builder = VictauriBuilder::new()
1583 .event_capacity(500)
1584 .recorder_capacity(2000);
1585 assert_eq!(builder.event_capacity, 500);
1586 assert_eq!(builder.recorder_capacity, 2000);
1587 }
1588
1589 #[test]
1590 fn builder_disable_tools() {
1591 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
1592 assert_eq!(builder.disabled_tools.len(), 2);
1593 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
1594 }
1595
1596 #[test]
1597 fn builder_command_allowlist() {
1598 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
1599 assert!(builder.command_allowlist.is_some());
1600 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
1601 }
1602
1603 #[test]
1604 fn builder_command_blocklist() {
1605 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
1606 assert_eq!(builder.command_blocklist.len(), 1);
1607 }
1608
1609 #[test]
1610 fn builder_redaction() {
1611 let builder = VictauriBuilder::new()
1612 .add_redaction_pattern(r"SECRET_\w+")
1613 .enable_redaction();
1614 assert!(builder.redaction_enabled);
1615 assert_eq!(builder.redaction_patterns.len(), 1);
1616 }
1617
1618 #[test]
1619 fn builder_strict_privacy_config() {
1620 let builder = VictauriBuilder::new().strict_privacy_mode();
1621 let config = builder.build_privacy_config();
1622 assert!(config.redaction_enabled);
1623 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
1624 assert!(!config.is_tool_enabled("eval_js"));
1625 assert!(!config.is_tool_enabled("screenshot"));
1626 assert!(!config.is_tool_enabled("interact"));
1627 assert!(config.is_tool_enabled("dom_snapshot"));
1628 }
1629
1630 #[test]
1631 fn builder_normal_privacy_config() {
1632 let builder = VictauriBuilder::new()
1633 .command_blocklist(&["secret_cmd"])
1634 .disable_tools(&["eval_js"]);
1635 let config = builder.build_privacy_config();
1636 assert!(config.command_blocklist.contains("secret_cmd"));
1637 assert!(!config.is_tool_enabled("eval_js"));
1638 assert!(!config.redaction_enabled);
1639 }
1640
1641 #[test]
1642 fn builder_strict_with_extra_blocklist() {
1643 let builder = VictauriBuilder::new()
1644 .strict_privacy_mode()
1645 .command_blocklist(&["extra_dangerous"]);
1646 let config = builder.build_privacy_config();
1647 assert!(config.command_blocklist.contains("extra_dangerous"));
1648 assert!(!config.is_tool_enabled("eval_js"));
1649 }
1650
1651 #[test]
1652 fn builder_test_profile() {
1653 let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
1654 let config = builder.build_privacy_config();
1655 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
1656 assert!(config.redaction_enabled);
1657 assert!(config.is_tool_enabled("interact"));
1658 assert!(config.is_tool_enabled("fill"));
1659 assert!(config.is_tool_enabled("recording"));
1660 assert!(!config.is_tool_enabled("eval_js"));
1661 assert!(!config.is_tool_enabled("screenshot"));
1662 assert!(!config.is_tool_enabled("navigate.go_to"));
1665 }
1666
1667 #[test]
1668 fn builder_profile_with_extra_disables() {
1669 let builder = VictauriBuilder::new()
1670 .privacy_profile(crate::privacy::PrivacyProfile::Test)
1671 .disable_tools(&["interact"]);
1672 let config = builder.build_privacy_config();
1673 assert!(!config.is_tool_enabled("interact"));
1674 assert!(config.is_tool_enabled("fill"));
1675 }
1676
1677 #[test]
1678 fn builder_bridge_capacities() {
1679 let builder = VictauriBuilder::new()
1680 .console_log_capacity(5000)
1681 .network_log_capacity(2000)
1682 .navigation_log_capacity(500);
1683 assert_eq!(builder.bridge_capacities.console_logs, 5000);
1684 assert_eq!(builder.bridge_capacities.network_log, 2000);
1685 assert_eq!(builder.bridge_capacities.navigation_log, 500);
1686 assert_eq!(builder.bridge_capacities.mutation_log, 500);
1687 assert_eq!(builder.bridge_capacities.dialog_log, 100);
1688 }
1689
1690 #[test]
1691 fn builder_on_ready_sets_callback() {
1692 let builder = VictauriBuilder::new().on_ready(|_port| {});
1693 assert!(builder.on_ready.is_some());
1694 }
1695
1696 #[test]
1697 fn builder_file_navigation_disabled_by_default() {
1698 let builder = VictauriBuilder::new();
1699 assert!(
1700 !builder.allow_file_navigation,
1701 "file navigation should be disabled by default"
1702 );
1703 }
1704
1705 #[test]
1706 fn builder_allow_file_navigation() {
1707 let builder = VictauriBuilder::new().allow_file_navigation();
1708 assert!(builder.allow_file_navigation);
1709 }
1710
1711 #[test]
1712 fn builder_listen_events() {
1713 let builder =
1714 VictauriBuilder::new().listen_events(&["notification-added", "settings-changed"]);
1715 assert_eq!(builder.listen_events.len(), 2);
1716 assert!(
1717 builder
1718 .listen_events
1719 .contains(&"notification-added".to_string())
1720 );
1721 assert!(
1722 builder
1723 .listen_events
1724 .contains(&"settings-changed".to_string())
1725 );
1726 }
1727
1728 #[test]
1729 fn builder_listen_events_empty_by_default() {
1730 let builder = VictauriBuilder::new();
1731 assert!(builder.listen_events.is_empty());
1732 }
1733
1734 #[test]
1735 fn init_script_contains_custom_capacities() {
1736 let caps = js_bridge::BridgeCapacities {
1737 console_logs: 3000,
1738 mutation_log: 750,
1739 network_log: 5000,
1740 navigation_log: 400,
1741 dialog_log: 250,
1742 long_tasks: 200,
1743 };
1744 let script = js_bridge::init_script(&caps);
1745 assert!(script.contains("CAP_CONSOLE = 3000"));
1746 assert!(script.contains("CAP_MUTATION = 750"));
1747 assert!(script.contains("CAP_NETWORK = 5000"));
1748 assert!(script.contains("CAP_NAVIGATION = 400"));
1749 assert!(script.contains("CAP_DIALOG = 250"));
1750 assert!(script.contains("CAP_LONG_TASKS = 200"));
1751 }
1752
1753 #[test]
1754 fn init_script_default_contains_standard_capacities() {
1755 let caps = js_bridge::BridgeCapacities::default();
1756 let script = js_bridge::init_script(&caps);
1757 assert!(script.contains("CAP_CONSOLE = 1000"));
1758 assert!(script.contains("CAP_NETWORK = 1000"));
1759 assert!(script.contains("window.__VICTAURI__"));
1760 }
1761
1762 #[test]
1763 fn builder_validates_defaults() {
1764 let builder = VictauriBuilder::new();
1765 assert!(builder.validate().is_ok());
1766 }
1767
1768 #[test]
1769 fn builder_rejects_zero_port() {
1770 let builder = VictauriBuilder::new().port(0);
1771 let err = builder.validate().unwrap_err();
1772 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
1773 }
1774
1775 #[test]
1776 fn builder_rejects_zero_event_capacity() {
1777 let builder = VictauriBuilder::new().event_capacity(0);
1778 let err = builder.validate().unwrap_err();
1779 assert!(matches!(
1780 err,
1781 BuilderError::InvalidEventCapacity { capacity: 0, .. }
1782 ));
1783 }
1784
1785 #[test]
1786 fn builder_rejects_excessive_event_capacity() {
1787 let builder = VictauriBuilder::new().event_capacity(2_000_000);
1788 assert!(builder.validate().is_err());
1789 }
1790
1791 #[test]
1792 fn builder_rejects_zero_recorder_capacity() {
1793 let builder = VictauriBuilder::new().recorder_capacity(0);
1794 assert!(builder.validate().is_err());
1795 }
1796
1797 #[test]
1798 fn builder_rejects_zero_eval_timeout() {
1799 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
1800 assert!(builder.validate().is_err());
1801 }
1802
1803 #[test]
1804 fn builder_rejects_excessive_eval_timeout() {
1805 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1806 assert!(builder.validate().is_err());
1807 }
1808
1809 #[test]
1810 fn builder_accepts_edge_values() {
1811 let builder = VictauriBuilder::new()
1812 .port(1)
1813 .event_capacity(1)
1814 .recorder_capacity(1)
1815 .eval_timeout(std::time::Duration::from_secs(1));
1816 assert!(builder.validate().is_ok());
1817
1818 let builder = VictauriBuilder::new()
1819 .port(65535)
1820 .event_capacity(MAX_EVENT_CAPACITY)
1821 .recorder_capacity(MAX_RECORDER_CAPACITY)
1822 .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1823 assert!(builder.validate().is_ok());
1824 }
1825}