1pub mod bridge;
35pub mod error;
36mod js_bridge;
37pub mod mcp;
38mod memory;
39pub mod privacy;
40pub mod redaction;
41pub(crate) mod screenshot;
42mod tools;
43
44pub mod auth;
45
46use std::collections::{HashMap, HashSet};
47use std::sync::Arc;
48use std::sync::atomic::AtomicU64;
49use tauri::plugin::{Builder, TauriPlugin};
50use tauri::{Manager, RunEvent, Runtime};
51use tokio::sync::{Mutex, oneshot, watch};
52use victauri_core::{CommandRegistry, EventLog, EventRecorder};
53
54pub use error::BuilderError;
55
56pub use victauri_macros::inspectable;
57
58const DEFAULT_PORT: u16 = 7373;
59const DEFAULT_EVENT_CAPACITY: usize = 10_000;
60const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
61const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
62
63pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
66
67pub struct VictauriState {
69 pub event_log: EventLog,
71 pub registry: CommandRegistry,
73 pub port: u16,
75 pub pending_evals: PendingCallbacks,
77 pub recorder: EventRecorder,
79 pub privacy: privacy::PrivacyConfig,
81 pub eval_timeout: std::time::Duration,
83 pub shutdown_tx: watch::Sender<bool>,
85 pub started_at: std::time::Instant,
87 pub tool_invocations: AtomicU64,
89}
90
91pub struct VictauriBuilder {
97 port: Option<u16>,
98 event_capacity: usize,
99 recorder_capacity: usize,
100 eval_timeout: std::time::Duration,
101 auth_token: Option<String>,
102 disabled_tools: Vec<String>,
103 command_allowlist: Option<Vec<String>>,
104 command_blocklist: Vec<String>,
105 redaction_patterns: Vec<String>,
106 redaction_enabled: bool,
107 strict_privacy: bool,
108 bridge_capacities: js_bridge::BridgeCapacities,
109 on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
110}
111
112impl Default for VictauriBuilder {
113 fn default() -> Self {
114 Self {
115 port: None,
116 event_capacity: DEFAULT_EVENT_CAPACITY,
117 recorder_capacity: DEFAULT_RECORDER_CAPACITY,
118 eval_timeout: DEFAULT_EVAL_TIMEOUT,
119 auth_token: None,
120 disabled_tools: Vec::new(),
121 command_allowlist: None,
122 command_blocklist: Vec::new(),
123 redaction_patterns: Vec::new(),
124 redaction_enabled: false,
125 strict_privacy: false,
126 bridge_capacities: js_bridge::BridgeCapacities::default(),
127 on_ready: None,
128 }
129 }
130}
131
132impl VictauriBuilder {
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 pub fn port(mut self, port: u16) -> Self {
139 self.port = Some(port);
140 self
141 }
142
143 pub fn event_capacity(mut self, capacity: usize) -> Self {
145 self.event_capacity = capacity;
146 self
147 }
148
149 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
151 self.recorder_capacity = capacity;
152 self
153 }
154
155 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
157 self.eval_timeout = timeout;
158 self
159 }
160
161 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
163 self.auth_token = Some(token.into());
164 self
165 }
166
167 pub fn generate_auth_token(mut self) -> Self {
169 self.auth_token = Some(auth::generate_token());
170 self
171 }
172
173 pub fn disable_tools(mut self, tools: &[&str]) -> Self {
175 self.disabled_tools = tools.iter().map(|s| s.to_string()).collect();
176 self
177 }
178
179 pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
181 self.command_allowlist = Some(commands.iter().map(|s| s.to_string()).collect());
182 self
183 }
184
185 pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
187 self.command_blocklist = commands.iter().map(|s| s.to_string()).collect();
188 self
189 }
190
191 pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
193 self.redaction_patterns.push(pattern.into());
194 self
195 }
196
197 pub fn enable_redaction(mut self) -> Self {
199 self.redaction_enabled = true;
200 self
201 }
202
203 pub fn strict_privacy_mode(mut self) -> Self {
207 self.strict_privacy = true;
208 self
209 }
210
211 pub fn console_log_capacity(mut self, capacity: usize) -> Self {
213 self.bridge_capacities.console_logs = capacity;
214 self
215 }
216
217 pub fn network_log_capacity(mut self, capacity: usize) -> Self {
219 self.bridge_capacities.network_log = capacity;
220 self
221 }
222
223 pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
225 self.bridge_capacities.navigation_log = capacity;
226 self
227 }
228
229 pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
232 self.on_ready = Some(Box::new(f));
233 self
234 }
235
236 fn resolve_port(&self) -> u16 {
237 self.port
238 .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
239 .unwrap_or(DEFAULT_PORT)
240 }
241
242 fn resolve_auth_token(&self) -> Option<String> {
243 self.auth_token
244 .clone()
245 .or_else(|| std::env::var("VICTAURI_AUTH_TOKEN").ok())
246 }
247
248 fn resolve_eval_timeout(&self) -> std::time::Duration {
249 std::env::var("VICTAURI_EVAL_TIMEOUT")
250 .ok()
251 .and_then(|s| s.parse::<u64>().ok())
252 .map(std::time::Duration::from_secs)
253 .unwrap_or(self.eval_timeout)
254 }
255
256 fn build_privacy_config(&self) -> privacy::PrivacyConfig {
257 if self.strict_privacy {
258 let mut config = privacy::strict_privacy_config();
259 for cmd in &self.command_blocklist {
260 config.command_blocklist.insert(cmd.clone());
261 }
262 if let Some(ref allow) = self.command_allowlist {
263 config.command_allowlist = Some(allow.iter().cloned().collect());
264 }
265 for tool in &self.disabled_tools {
266 config.disabled_tools.insert(tool.clone());
267 }
268 if !self.redaction_patterns.is_empty() {
269 config.redactor = redaction::Redactor::new(&self.redaction_patterns);
270 }
271 config
272 } else {
273 privacy::PrivacyConfig {
274 command_allowlist: self
275 .command_allowlist
276 .as_ref()
277 .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
278 command_blocklist: self.command_blocklist.iter().cloned().collect(),
279 disabled_tools: self.disabled_tools.iter().cloned().collect(),
280 redactor: redaction::Redactor::new(&self.redaction_patterns),
281 redaction_enabled: self.redaction_enabled,
282 }
283 }
284 }
285
286 fn validate(&self) -> Result<(), BuilderError> {
287 let port = self.resolve_port();
288 if port == 0 {
289 return Err(BuilderError::InvalidPort {
290 port,
291 reason: "port 0 is reserved".to_string(),
292 });
293 }
294
295 if self.event_capacity == 0 || self.event_capacity > 1_000_000 {
296 return Err(BuilderError::InvalidEventCapacity {
297 capacity: self.event_capacity,
298 reason: "must be between 1 and 1,000,000".to_string(),
299 });
300 }
301
302 if self.recorder_capacity == 0 || self.recorder_capacity > 1_000_000 {
303 return Err(BuilderError::InvalidRecorderCapacity {
304 capacity: self.recorder_capacity,
305 reason: "must be between 1 and 1,000,000".to_string(),
306 });
307 }
308
309 let timeout = self.resolve_eval_timeout();
310 if timeout.as_secs() == 0 || timeout.as_secs() > 300 {
311 return Err(BuilderError::InvalidEvalTimeout {
312 timeout_secs: timeout.as_secs(),
313 reason: "must be between 1 and 300 seconds".to_string(),
314 });
315 }
316
317 Ok(())
318 }
319
320 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
321 #[cfg(not(debug_assertions))]
322 {
323 Ok(Builder::new("victauri").build())
324 }
325
326 #[cfg(debug_assertions)]
327 {
328 self.validate()?;
329
330 let port = self.resolve_port();
331 let event_capacity = self.event_capacity;
332 let recorder_capacity = self.recorder_capacity;
333 let eval_timeout = self.resolve_eval_timeout();
334 let auth_token = self.resolve_auth_token();
335 let privacy_config = self.build_privacy_config();
336 let on_ready = self.on_ready;
337 let js_init = js_bridge::init_script(&self.bridge_capacities);
338
339 Ok(Builder::new("victauri")
340 .setup(move |app, _api| {
341 let event_log = EventLog::new(event_capacity);
342 let registry = CommandRegistry::new();
343 let (shutdown_tx, shutdown_rx) = watch::channel(false);
344
345 let state = Arc::new(VictauriState {
346 event_log,
347 registry,
348 port,
349 pending_evals: Arc::new(Mutex::new(HashMap::new())),
350 recorder: EventRecorder::new(recorder_capacity),
351 privacy: privacy_config,
352 eval_timeout,
353 shutdown_tx,
354 started_at: std::time::Instant::now(),
355 tool_invocations: AtomicU64::new(0),
356 });
357
358 app.manage(state.clone());
359
360 if let Some(ref token) = auth_token {
361 tracing::info!(
362 "Victauri MCP server auth token: [REDACTED] (check VICTAURI_AUTH_TOKEN env var)"
363 );
364 tracing::debug!("Auth token value: {token}");
365 }
366
367 let app_handle = app.clone();
368 tauri::async_runtime::spawn(async move {
369 match mcp::start_server_with_options(
370 app_handle, state, port, auth_token, shutdown_rx,
371 )
372 .await
373 {
374 Ok(()) => {
375 tracing::info!("Victauri MCP server stopped");
376 }
377 Err(e) => {
378 tracing::error!("Victauri MCP server failed: {e}");
379 }
380 }
381 });
382
383 if let Some(cb) = on_ready {
384 let ready_port = port;
385 tauri::async_runtime::spawn(async move {
386 for _ in 0..50 {
387 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
388 if tokio::net::TcpStream::connect(format!(
389 "127.0.0.1:{ready_port}"
390 ))
391 .await
392 .is_ok()
393 {
394 cb(ready_port);
395 return;
396 }
397 }
398 tracing::warn!("Victauri on_ready: server did not become ready within 5s");
399 cb(ready_port);
400 });
401 }
402
403 tracing::info!("Victauri plugin initialized — MCP server on port {port}");
404 Ok(())
405 })
406 .on_event(|app, event| {
407 if let RunEvent::Exit = event
408 && let Some(state) = app.try_state::<Arc<VictauriState>>()
409 {
410 let _ = state.shutdown_tx.send(true);
411 tracing::info!("Victauri shutdown signal sent");
412 }
413 })
414 .js_init_script(js_init)
415 .invoke_handler(tauri::generate_handler![
416 tools::victauri_eval_js,
417 tools::victauri_eval_callback,
418 tools::victauri_get_window_state,
419 tools::victauri_list_windows,
420 tools::victauri_get_ipc_log,
421 tools::victauri_get_registry,
422 tools::victauri_get_memory_stats,
423 tools::victauri_dom_snapshot,
424 tools::victauri_verify_state,
425 tools::victauri_detect_ghost_commands,
426 tools::victauri_check_ipc_integrity,
427 ])
428 .build())
429 }
430 }
431}
432
433pub fn init<R: Runtime>() -> TauriPlugin<R> {
443 VictauriBuilder::new()
444 .build()
445 .expect("default Victauri configuration is always valid")
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn builder_default_values() {
454 let builder = VictauriBuilder::new();
455 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
456 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
457 assert!(builder.auth_token.is_none());
458 assert!(builder.disabled_tools.is_empty());
459 assert!(builder.command_allowlist.is_none());
460 assert!(builder.command_blocklist.is_empty());
461 assert!(!builder.redaction_enabled);
462 assert!(!builder.strict_privacy);
463 }
464
465 #[test]
466 fn builder_port_override() {
467 let builder = VictauriBuilder::new().port(9090);
468 assert_eq!(builder.resolve_port(), 9090);
469 }
470
471 #[test]
472 fn builder_default_port() {
473 let builder = VictauriBuilder::new();
474 unsafe { std::env::remove_var("VICTAURI_PORT") };
476 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
477 }
478
479 #[test]
480 fn builder_auth_token_explicit() {
481 let builder = VictauriBuilder::new().auth_token("my-secret");
482 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
483 }
484
485 #[test]
486 fn builder_auth_token_generated() {
487 let builder = VictauriBuilder::new().generate_auth_token();
488 let token = builder.resolve_auth_token().unwrap();
489 assert_eq!(token.len(), 36);
490 }
491
492 #[test]
493 fn builder_capacities() {
494 let builder = VictauriBuilder::new()
495 .event_capacity(500)
496 .recorder_capacity(2000);
497 assert_eq!(builder.event_capacity, 500);
498 assert_eq!(builder.recorder_capacity, 2000);
499 }
500
501 #[test]
502 fn builder_disable_tools() {
503 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
504 assert_eq!(builder.disabled_tools.len(), 2);
505 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
506 }
507
508 #[test]
509 fn builder_command_allowlist() {
510 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
511 assert!(builder.command_allowlist.is_some());
512 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
513 }
514
515 #[test]
516 fn builder_command_blocklist() {
517 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
518 assert_eq!(builder.command_blocklist.len(), 1);
519 }
520
521 #[test]
522 fn builder_redaction() {
523 let builder = VictauriBuilder::new()
524 .add_redaction_pattern(r"SECRET_\w+")
525 .enable_redaction();
526 assert!(builder.redaction_enabled);
527 assert_eq!(builder.redaction_patterns.len(), 1);
528 }
529
530 #[test]
531 fn builder_strict_privacy_config() {
532 let builder = VictauriBuilder::new().strict_privacy_mode();
533 let config = builder.build_privacy_config();
534 assert!(config.redaction_enabled);
535 assert!(!config.disabled_tools.is_empty());
536 assert!(config.disabled_tools.contains("eval_js"));
537 assert!(config.disabled_tools.contains("screenshot"));
538 }
539
540 #[test]
541 fn builder_normal_privacy_config() {
542 let builder = VictauriBuilder::new()
543 .command_blocklist(&["secret_cmd"])
544 .disable_tools(&["eval_js"]);
545 let config = builder.build_privacy_config();
546 assert!(config.command_blocklist.contains("secret_cmd"));
547 assert!(config.disabled_tools.contains("eval_js"));
548 assert!(!config.redaction_enabled);
549 }
550
551 #[test]
552 fn builder_strict_with_extra_blocklist() {
553 let builder = VictauriBuilder::new()
554 .strict_privacy_mode()
555 .command_blocklist(&["extra_dangerous"]);
556 let config = builder.build_privacy_config();
557 assert!(config.command_blocklist.contains("extra_dangerous"));
558 assert!(config.disabled_tools.contains("eval_js"));
559 }
560
561 #[test]
562 fn builder_bridge_capacities() {
563 let builder = VictauriBuilder::new()
564 .console_log_capacity(5000)
565 .network_log_capacity(2000)
566 .navigation_log_capacity(500);
567 assert_eq!(builder.bridge_capacities.console_logs, 5000);
568 assert_eq!(builder.bridge_capacities.network_log, 2000);
569 assert_eq!(builder.bridge_capacities.navigation_log, 500);
570 assert_eq!(builder.bridge_capacities.mutation_log, 500);
571 assert_eq!(builder.bridge_capacities.dialog_log, 100);
572 }
573
574 #[test]
575 fn builder_on_ready_sets_callback() {
576 let builder = VictauriBuilder::new().on_ready(|_port| {});
577 assert!(builder.on_ready.is_some());
578 }
579
580 #[test]
581 fn init_script_contains_custom_capacities() {
582 let caps = js_bridge::BridgeCapacities {
583 console_logs: 3000,
584 mutation_log: 750,
585 network_log: 5000,
586 navigation_log: 400,
587 dialog_log: 250,
588 long_tasks: 200,
589 };
590 let script = js_bridge::init_script(&caps);
591 assert!(script.contains("CAP_CONSOLE = 3000"));
592 assert!(script.contains("CAP_MUTATION = 750"));
593 assert!(script.contains("CAP_NETWORK = 5000"));
594 assert!(script.contains("CAP_NAVIGATION = 400"));
595 assert!(script.contains("CAP_DIALOG = 250"));
596 assert!(script.contains("CAP_LONG_TASKS = 200"));
597 }
598
599 #[test]
600 fn init_script_default_contains_standard_capacities() {
601 let caps = js_bridge::BridgeCapacities::default();
602 let script = js_bridge::init_script(&caps);
603 assert!(script.contains("CAP_CONSOLE = 1000"));
604 assert!(script.contains("CAP_NETWORK = 1000"));
605 assert!(script.contains("window.__VICTAURI__"));
606 }
607
608 #[test]
609 fn builder_validates_defaults() {
610 let builder = VictauriBuilder::new();
611 assert!(builder.validate().is_ok());
612 }
613
614 #[test]
615 fn builder_rejects_zero_port() {
616 let builder = VictauriBuilder::new().port(0);
617 let err = builder.validate().unwrap_err();
618 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
619 }
620
621 #[test]
622 fn builder_rejects_zero_event_capacity() {
623 let builder = VictauriBuilder::new().event_capacity(0);
624 let err = builder.validate().unwrap_err();
625 assert!(matches!(
626 err,
627 BuilderError::InvalidEventCapacity { capacity: 0, .. }
628 ));
629 }
630
631 #[test]
632 fn builder_rejects_excessive_event_capacity() {
633 let builder = VictauriBuilder::new().event_capacity(2_000_000);
634 assert!(builder.validate().is_err());
635 }
636
637 #[test]
638 fn builder_rejects_zero_recorder_capacity() {
639 let builder = VictauriBuilder::new().recorder_capacity(0);
640 assert!(builder.validate().is_err());
641 }
642
643 #[test]
644 fn builder_rejects_zero_eval_timeout() {
645 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
646 assert!(builder.validate().is_err());
647 }
648
649 #[test]
650 fn builder_rejects_excessive_eval_timeout() {
651 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
652 assert!(builder.validate().is_err());
653 }
654
655 #[test]
656 fn builder_accepts_edge_values() {
657 let builder = VictauriBuilder::new()
658 .port(1)
659 .event_capacity(1)
660 .recorder_capacity(1)
661 .eval_timeout(std::time::Duration::from_secs(1));
662 assert!(builder.validate().is_ok());
663
664 let builder = VictauriBuilder::new()
665 .port(65535)
666 .event_capacity(1_000_000)
667 .recorder_capacity(1_000_000)
668 .eval_timeout(std::time::Duration::from_secs(300));
669 assert!(builder.validate().is_ok());
670 }
671}