1#![deny(missing_docs)]
2pub mod bridge;
39pub mod error;
40pub mod js_bridge;
42pub mod mcp;
44mod memory;
45pub mod privacy;
47pub mod redaction;
49pub(crate) mod screenshot;
50mod tools;
51
52pub mod auth;
54
55use std::collections::{HashMap, HashSet};
56use std::sync::Arc;
57use std::sync::atomic::{AtomicU16, AtomicU64};
58use tauri::plugin::{Builder, TauriPlugin};
59use tauri::{Manager, RunEvent, Runtime};
60use tokio::sync::{Mutex, oneshot, watch};
61use victauri_core::{CommandRegistry, EventLog, EventRecorder};
62
63pub use error::BuilderError;
64
65pub use victauri_core::CommandInfo;
66pub use victauri_macros::inspectable;
67
68#[macro_export]
89macro_rules! register_commands {
90 ($app:expr, $($schema_call:expr),+ $(,)?) => {{
91 let state = $app.state::<std::sync::Arc<$crate::VictauriState>>();
92 $(
93 state.registry.register($schema_call);
94 )+
95 }};
96}
97
98const DEFAULT_PORT: u16 = 7373;
99const DEFAULT_EVENT_CAPACITY: usize = 10_000;
100const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
101const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
102const MAX_EVENT_CAPACITY: usize = 1_000_000;
103const MAX_RECORDER_CAPACITY: usize = 1_000_000;
104const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
105
106pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
109
110pub struct VictauriState {
112 pub event_log: EventLog,
114 pub registry: CommandRegistry,
116 pub port: AtomicU16,
118 pub pending_evals: PendingCallbacks,
120 pub recorder: EventRecorder,
122 pub privacy: privacy::PrivacyConfig,
124 pub eval_timeout: std::time::Duration,
126 pub shutdown_tx: watch::Sender<bool>,
128 pub started_at: std::time::Instant,
130 pub tool_invocations: AtomicU64,
132}
133
134pub struct VictauriBuilder {
145 port: Option<u16>,
146 event_capacity: usize,
147 recorder_capacity: usize,
148 eval_timeout: std::time::Duration,
149 auth_token: Option<String>,
150 auth_explicitly_disabled: bool,
151 disabled_tools: Vec<String>,
152 command_allowlist: Option<Vec<String>>,
153 command_blocklist: Vec<String>,
154 redaction_patterns: Vec<String>,
155 redaction_enabled: bool,
156 strict_privacy: bool,
157 bridge_capacities: js_bridge::BridgeCapacities,
158 on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
159 commands: Vec<victauri_core::CommandInfo>,
160}
161
162impl Default for VictauriBuilder {
163 fn default() -> Self {
164 Self {
165 port: None,
166 event_capacity: DEFAULT_EVENT_CAPACITY,
167 recorder_capacity: DEFAULT_RECORDER_CAPACITY,
168 eval_timeout: DEFAULT_EVAL_TIMEOUT,
169 auth_token: None,
170 auth_explicitly_disabled: false,
171 disabled_tools: Vec::new(),
172 command_allowlist: None,
173 command_blocklist: Vec::new(),
174 redaction_patterns: Vec::new(),
175 redaction_enabled: false,
176 strict_privacy: false,
177 bridge_capacities: js_bridge::BridgeCapacities::default(),
178 on_ready: None,
179 commands: Vec::new(),
180 }
181 }
182}
183
184impl VictauriBuilder {
185 #[must_use]
187 pub fn new() -> Self {
188 Self::default()
189 }
190
191 #[must_use]
193 pub fn port(mut self, port: u16) -> Self {
194 self.port = Some(port);
195 self
196 }
197
198 #[must_use]
200 pub fn event_capacity(mut self, capacity: usize) -> Self {
201 self.event_capacity = capacity;
202 self
203 }
204
205 #[must_use]
207 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
208 self.recorder_capacity = capacity;
209 self
210 }
211
212 #[must_use]
214 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
215 self.eval_timeout = timeout;
216 self
217 }
218
219 #[must_use]
221 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
222 self.auth_token = Some(token.into());
223 self
224 }
225
226 #[must_use]
228 pub fn generate_auth_token(mut self) -> Self {
229 self.auth_token = Some(auth::generate_token());
230 self
231 }
232
233 #[must_use]
239 pub fn auth_disabled(mut self) -> Self {
240 self.auth_explicitly_disabled = true;
241 self.auth_token = None;
242 self
243 }
244
245 #[must_use]
247 pub fn disable_tools(mut self, tools: &[&str]) -> Self {
248 self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
249 self
250 }
251
252 #[must_use]
254 pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
255 self.command_allowlist = Some(
256 commands
257 .iter()
258 .map(std::string::ToString::to_string)
259 .collect(),
260 );
261 self
262 }
263
264 #[must_use]
266 pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
267 self.command_blocklist = commands
268 .iter()
269 .map(std::string::ToString::to_string)
270 .collect();
271 self
272 }
273
274 #[must_use]
276 pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
277 self.redaction_patterns.push(pattern.into());
278 self
279 }
280
281 #[must_use]
283 pub fn enable_redaction(mut self) -> Self {
284 self.redaction_enabled = true;
285 self
286 }
287
288 #[must_use]
292 pub fn strict_privacy_mode(mut self) -> Self {
293 self.strict_privacy = true;
294 self
295 }
296
297 #[must_use]
299 pub fn console_log_capacity(mut self, capacity: usize) -> Self {
300 self.bridge_capacities.console_logs = capacity;
301 self
302 }
303
304 #[must_use]
306 pub fn network_log_capacity(mut self, capacity: usize) -> Self {
307 self.bridge_capacities.network_log = capacity;
308 self
309 }
310
311 #[must_use]
313 pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
314 self.bridge_capacities.navigation_log = capacity;
315 self
316 }
317
318 #[must_use]
333 pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
334 self.commands = schemas.to_vec();
335 self
336 }
337
338 #[must_use]
354 pub fn auto_discover(mut self) -> Self {
355 self.commands
356 .extend(victauri_core::auto_discovered_commands());
357 self
358 }
359
360 #[must_use]
363 pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
364 self.on_ready = Some(Box::new(f));
365 self
366 }
367
368 fn resolve_port(&self) -> u16 {
369 self.port
370 .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
371 .unwrap_or(DEFAULT_PORT)
372 }
373
374 fn resolve_auth_token(&self) -> Option<String> {
375 if self.auth_explicitly_disabled {
376 return None;
377 }
378 self.auth_token
379 .clone()
380 .or_else(|| std::env::var("VICTAURI_AUTH_TOKEN").ok())
381 .or_else(|| Some(auth::generate_token()))
382 }
383
384 fn resolve_eval_timeout(&self) -> std::time::Duration {
385 std::env::var("VICTAURI_EVAL_TIMEOUT")
386 .ok()
387 .and_then(|s| s.parse::<u64>().ok())
388 .map_or(self.eval_timeout, std::time::Duration::from_secs)
389 }
390
391 fn build_privacy_config(&self) -> privacy::PrivacyConfig {
392 if self.strict_privacy {
393 let mut config = privacy::strict_privacy_config();
394 for cmd in &self.command_blocklist {
395 config.command_blocklist.insert(cmd.clone());
396 }
397 if let Some(ref allow) = self.command_allowlist {
398 config.command_allowlist = Some(allow.iter().cloned().collect());
399 }
400 for tool in &self.disabled_tools {
401 config.disabled_tools.insert(tool.clone());
402 }
403 if !self.redaction_patterns.is_empty() {
404 config.redactor = redaction::Redactor::new(&self.redaction_patterns);
405 }
406 config
407 } else {
408 privacy::PrivacyConfig {
409 command_allowlist: self
410 .command_allowlist
411 .as_ref()
412 .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
413 command_blocklist: self.command_blocklist.iter().cloned().collect(),
414 disabled_tools: self.disabled_tools.iter().cloned().collect(),
415 redactor: redaction::Redactor::new(&self.redaction_patterns),
416 redaction_enabled: self.redaction_enabled,
417 }
418 }
419 }
420
421 fn validate(&self) -> Result<(), BuilderError> {
422 let port = self.resolve_port();
423 if port == 0 {
424 return Err(BuilderError::InvalidPort {
425 port,
426 reason: "port 0 is reserved".to_string(),
427 });
428 }
429
430 if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
431 return Err(BuilderError::InvalidEventCapacity {
432 capacity: self.event_capacity,
433 reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
434 });
435 }
436
437 if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
438 return Err(BuilderError::InvalidRecorderCapacity {
439 capacity: self.recorder_capacity,
440 reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
441 });
442 }
443
444 let timeout = self.resolve_eval_timeout();
445 if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
446 return Err(BuilderError::InvalidEvalTimeout {
447 timeout_secs: timeout.as_secs(),
448 reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
449 });
450 }
451
452 Ok(())
453 }
454
455 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
465 #[cfg(not(debug_assertions))]
466 {
467 Ok(Builder::new("victauri").build())
468 }
469
470 #[cfg(debug_assertions)]
471 {
472 self.validate()?;
473
474 let port = self.resolve_port();
475 let event_capacity = self.event_capacity;
476 let recorder_capacity = self.recorder_capacity;
477 let eval_timeout = self.resolve_eval_timeout();
478 let auth_token = self.resolve_auth_token();
479 let privacy_config = self.build_privacy_config();
480 let on_ready = self.on_ready;
481 let commands = self.commands;
482 let js_init = js_bridge::init_script(&self.bridge_capacities);
483
484 Ok(Builder::new("victauri")
485 .setup(move |app, _api| {
486 let event_log = EventLog::new(event_capacity);
487 let registry = CommandRegistry::new();
488 let (shutdown_tx, shutdown_rx) = watch::channel(false);
489
490 let state = Arc::new(VictauriState {
491 event_log,
492 registry,
493 port: AtomicU16::new(port),
494 pending_evals: Arc::new(Mutex::new(HashMap::new())),
495 recorder: EventRecorder::new(recorder_capacity),
496 privacy: privacy_config,
497 eval_timeout,
498 shutdown_tx,
499 started_at: std::time::Instant::now(),
500 tool_invocations: AtomicU64::new(0),
501 });
502
503 app.manage(state.clone());
504
505 for cmd in commands {
506 state.registry.register(cmd);
507 }
508
509 if let Some(ref token) = auth_token {
510 tracing::info!(
511 "Victauri MCP server auth enabled — token: {token}"
512 );
513 } else {
514 tracing::warn!(
515 "Victauri MCP server auth DISABLED — any localhost process can access the MCP server"
516 );
517 }
518
519 let app_handle = app.clone();
520 let ready_state = state.clone();
521 tauri::async_runtime::spawn(async move {
522 match mcp::start_server_with_options(
523 app_handle, state, port, auth_token, shutdown_rx,
524 )
525 .await
526 {
527 Ok(()) => {
528 tracing::info!("Victauri MCP server stopped");
529 }
530 Err(e) => {
531 tracing::error!("Victauri MCP server failed: {e}");
532 }
533 }
534 });
535
536 if let Some(cb) = on_ready {
537 tauri::async_runtime::spawn(async move {
538 for _ in 0..50 {
539 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
540 let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
541 if tokio::net::TcpStream::connect(format!(
542 "127.0.0.1:{actual_port}"
543 ))
544 .await
545 .is_ok()
546 {
547 cb(actual_port);
548 return;
549 }
550 }
551 let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
552 tracing::warn!("Victauri on_ready: server did not become ready within 5s");
553 cb(actual_port);
554 });
555 }
556
557 tracing::info!("Victauri plugin initialized — MCP server on port {port}");
558 Ok(())
559 })
560 .on_event(|app, event| {
561 if let RunEvent::Exit = event
562 && let Some(state) = app.try_state::<Arc<VictauriState>>()
563 {
564 let _ = state.shutdown_tx.send(true);
565 tracing::info!("Victauri shutdown signal sent");
566 }
567 })
568 .js_init_script(js_init)
569 .invoke_handler(tauri::generate_handler![
570 tools::victauri_eval_js,
571 tools::victauri_eval_callback,
572 tools::victauri_get_window_state,
573 tools::victauri_list_windows,
574 tools::victauri_get_ipc_log,
575 tools::victauri_get_registry,
576 tools::victauri_get_memory_stats,
577 tools::victauri_dom_snapshot,
578 tools::victauri_verify_state,
579 tools::victauri_detect_ghost_commands,
580 tools::victauri_check_ipc_integrity,
581 ])
582 .build())
583 }
584 }
585}
586
587#[must_use]
601pub fn init<R: Runtime>() -> TauriPlugin<R> {
602 VictauriBuilder::new()
603 .build()
604 .expect("default Victauri configuration is always valid")
605}
606
607#[must_use]
616pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
617 VictauriBuilder::new()
618 .auto_discover()
619 .build()
620 .expect("default Victauri configuration is always valid")
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 fn builder_default_values() {
629 let builder = VictauriBuilder::new();
630 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
631 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
632 assert!(builder.auth_token.is_none());
634 assert!(!builder.auth_explicitly_disabled);
635 let resolved = builder.resolve_auth_token();
636 assert!(resolved.is_some(), "auth should be enabled by default");
637 assert_eq!(
638 resolved.unwrap().len(),
639 36,
640 "auto-generated token should be a UUID"
641 );
642 assert!(builder.disabled_tools.is_empty());
643 assert!(builder.command_allowlist.is_none());
644 assert!(builder.command_blocklist.is_empty());
645 assert!(!builder.redaction_enabled);
646 assert!(!builder.strict_privacy);
647 }
648
649 #[test]
650 fn builder_port_override() {
651 let builder = VictauriBuilder::new().port(9090);
652 assert_eq!(builder.resolve_port(), 9090);
653 }
654
655 #[test]
656 #[allow(unsafe_code)]
657 fn builder_default_port() {
658 let builder = VictauriBuilder::new();
659 unsafe { std::env::remove_var("VICTAURI_PORT") };
661 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
662 }
663
664 #[test]
665 fn builder_auth_token_explicit() {
666 let builder = VictauriBuilder::new().auth_token("my-secret");
667 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
668 }
669
670 #[test]
671 fn builder_auth_token_generated() {
672 let builder = VictauriBuilder::new().generate_auth_token();
673 let token = builder.resolve_auth_token().unwrap();
674 assert_eq!(token.len(), 36);
675 }
676
677 #[test]
678 fn builder_auth_disabled() {
679 let builder = VictauriBuilder::new().auth_disabled();
680 assert!(builder.auth_explicitly_disabled);
681 assert!(
682 builder.resolve_auth_token().is_none(),
683 "auth_disabled should opt out of auto-generated token"
684 );
685 }
686
687 #[test]
688 fn builder_auth_disabled_overrides_explicit_token() {
689 let builder = VictauriBuilder::new()
690 .auth_token("my-secret")
691 .auth_disabled();
692 assert!(
693 builder.resolve_auth_token().is_none(),
694 "auth_disabled should override explicit token"
695 );
696 }
697
698 #[test]
699 fn builder_capacities() {
700 let builder = VictauriBuilder::new()
701 .event_capacity(500)
702 .recorder_capacity(2000);
703 assert_eq!(builder.event_capacity, 500);
704 assert_eq!(builder.recorder_capacity, 2000);
705 }
706
707 #[test]
708 fn builder_disable_tools() {
709 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
710 assert_eq!(builder.disabled_tools.len(), 2);
711 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
712 }
713
714 #[test]
715 fn builder_command_allowlist() {
716 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
717 assert!(builder.command_allowlist.is_some());
718 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
719 }
720
721 #[test]
722 fn builder_command_blocklist() {
723 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
724 assert_eq!(builder.command_blocklist.len(), 1);
725 }
726
727 #[test]
728 fn builder_redaction() {
729 let builder = VictauriBuilder::new()
730 .add_redaction_pattern(r"SECRET_\w+")
731 .enable_redaction();
732 assert!(builder.redaction_enabled);
733 assert_eq!(builder.redaction_patterns.len(), 1);
734 }
735
736 #[test]
737 fn builder_strict_privacy_config() {
738 let builder = VictauriBuilder::new().strict_privacy_mode();
739 let config = builder.build_privacy_config();
740 assert!(config.redaction_enabled);
741 assert!(!config.disabled_tools.is_empty());
742 assert!(config.disabled_tools.contains("eval_js"));
743 assert!(config.disabled_tools.contains("screenshot"));
744 }
745
746 #[test]
747 fn builder_normal_privacy_config() {
748 let builder = VictauriBuilder::new()
749 .command_blocklist(&["secret_cmd"])
750 .disable_tools(&["eval_js"]);
751 let config = builder.build_privacy_config();
752 assert!(config.command_blocklist.contains("secret_cmd"));
753 assert!(config.disabled_tools.contains("eval_js"));
754 assert!(!config.redaction_enabled);
755 }
756
757 #[test]
758 fn builder_strict_with_extra_blocklist() {
759 let builder = VictauriBuilder::new()
760 .strict_privacy_mode()
761 .command_blocklist(&["extra_dangerous"]);
762 let config = builder.build_privacy_config();
763 assert!(config.command_blocklist.contains("extra_dangerous"));
764 assert!(config.disabled_tools.contains("eval_js"));
765 }
766
767 #[test]
768 fn builder_bridge_capacities() {
769 let builder = VictauriBuilder::new()
770 .console_log_capacity(5000)
771 .network_log_capacity(2000)
772 .navigation_log_capacity(500);
773 assert_eq!(builder.bridge_capacities.console_logs, 5000);
774 assert_eq!(builder.bridge_capacities.network_log, 2000);
775 assert_eq!(builder.bridge_capacities.navigation_log, 500);
776 assert_eq!(builder.bridge_capacities.mutation_log, 500);
777 assert_eq!(builder.bridge_capacities.dialog_log, 100);
778 }
779
780 #[test]
781 fn builder_on_ready_sets_callback() {
782 let builder = VictauriBuilder::new().on_ready(|_port| {});
783 assert!(builder.on_ready.is_some());
784 }
785
786 #[test]
787 fn init_script_contains_custom_capacities() {
788 let caps = js_bridge::BridgeCapacities {
789 console_logs: 3000,
790 mutation_log: 750,
791 network_log: 5000,
792 navigation_log: 400,
793 dialog_log: 250,
794 long_tasks: 200,
795 };
796 let script = js_bridge::init_script(&caps);
797 assert!(script.contains("CAP_CONSOLE = 3000"));
798 assert!(script.contains("CAP_MUTATION = 750"));
799 assert!(script.contains("CAP_NETWORK = 5000"));
800 assert!(script.contains("CAP_NAVIGATION = 400"));
801 assert!(script.contains("CAP_DIALOG = 250"));
802 assert!(script.contains("CAP_LONG_TASKS = 200"));
803 }
804
805 #[test]
806 fn init_script_default_contains_standard_capacities() {
807 let caps = js_bridge::BridgeCapacities::default();
808 let script = js_bridge::init_script(&caps);
809 assert!(script.contains("CAP_CONSOLE = 1000"));
810 assert!(script.contains("CAP_NETWORK = 1000"));
811 assert!(script.contains("window.__VICTAURI__"));
812 }
813
814 #[test]
815 fn builder_validates_defaults() {
816 let builder = VictauriBuilder::new();
817 assert!(builder.validate().is_ok());
818 }
819
820 #[test]
821 fn builder_rejects_zero_port() {
822 let builder = VictauriBuilder::new().port(0);
823 let err = builder.validate().unwrap_err();
824 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
825 }
826
827 #[test]
828 fn builder_rejects_zero_event_capacity() {
829 let builder = VictauriBuilder::new().event_capacity(0);
830 let err = builder.validate().unwrap_err();
831 assert!(matches!(
832 err,
833 BuilderError::InvalidEventCapacity { capacity: 0, .. }
834 ));
835 }
836
837 #[test]
838 fn builder_rejects_excessive_event_capacity() {
839 let builder = VictauriBuilder::new().event_capacity(2_000_000);
840 assert!(builder.validate().is_err());
841 }
842
843 #[test]
844 fn builder_rejects_zero_recorder_capacity() {
845 let builder = VictauriBuilder::new().recorder_capacity(0);
846 assert!(builder.validate().is_err());
847 }
848
849 #[test]
850 fn builder_rejects_zero_eval_timeout() {
851 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
852 assert!(builder.validate().is_err());
853 }
854
855 #[test]
856 fn builder_rejects_excessive_eval_timeout() {
857 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
858 assert!(builder.validate().is_err());
859 }
860
861 #[test]
862 fn builder_accepts_edge_values() {
863 let builder = VictauriBuilder::new()
864 .port(1)
865 .event_capacity(1)
866 .recorder_capacity(1)
867 .eval_timeout(std::time::Duration::from_secs(1));
868 assert!(builder.validate().is_ok());
869
870 let builder = VictauriBuilder::new()
871 .port(65535)
872 .event_capacity(MAX_EVENT_CAPACITY)
873 .recorder_capacity(MAX_RECORDER_CAPACITY)
874 .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
875 assert!(builder.validate().is_ok());
876 }
877}