#![deny(missing_docs)]
pub mod bridge;
pub mod error;
pub mod js_bridge;
pub mod mcp;
mod memory;
pub mod privacy;
pub mod redaction;
pub(crate) mod screenshot;
mod tools;
pub mod auth;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU16, AtomicU64};
use tauri::plugin::{Builder, TauriPlugin};
use tauri::{Manager, RunEvent, Runtime};
use tokio::sync::{Mutex, oneshot, watch};
use victauri_core::{CommandRegistry, EventLog, EventRecorder};
pub use error::BuilderError;
pub use victauri_core::CommandInfo;
pub use victauri_macros::inspectable;
#[macro_export]
macro_rules! register_commands {
($app:expr, $($schema_call:expr),+ $(,)?) => {{
let state = $app.state::<std::sync::Arc<$crate::VictauriState>>();
$(
state.registry.register($schema_call);
)+
}};
}
const DEFAULT_PORT: u16 = 7373;
const DEFAULT_EVENT_CAPACITY: usize = 10_000;
const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const MAX_EVENT_CAPACITY: usize = 1_000_000;
const MAX_RECORDER_CAPACITY: usize = 1_000_000;
const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
pub struct VictauriState {
pub event_log: EventLog,
pub registry: CommandRegistry,
pub port: AtomicU16,
pub pending_evals: PendingCallbacks,
pub recorder: EventRecorder,
pub privacy: privacy::PrivacyConfig,
pub eval_timeout: std::time::Duration,
pub shutdown_tx: watch::Sender<bool>,
pub started_at: std::time::Instant,
pub tool_invocations: AtomicU64,
}
pub struct VictauriBuilder {
port: Option<u16>,
event_capacity: usize,
recorder_capacity: usize,
eval_timeout: std::time::Duration,
auth_token: Option<String>,
auth_explicitly_disabled: bool,
disabled_tools: Vec<String>,
command_allowlist: Option<Vec<String>>,
command_blocklist: Vec<String>,
redaction_patterns: Vec<String>,
redaction_enabled: bool,
strict_privacy: bool,
bridge_capacities: js_bridge::BridgeCapacities,
on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
commands: Vec<victauri_core::CommandInfo>,
}
impl Default for VictauriBuilder {
fn default() -> Self {
Self {
port: None,
event_capacity: DEFAULT_EVENT_CAPACITY,
recorder_capacity: DEFAULT_RECORDER_CAPACITY,
eval_timeout: DEFAULT_EVAL_TIMEOUT,
auth_token: None,
auth_explicitly_disabled: false,
disabled_tools: Vec::new(),
command_allowlist: None,
command_blocklist: Vec::new(),
redaction_patterns: Vec::new(),
redaction_enabled: false,
strict_privacy: false,
bridge_capacities: js_bridge::BridgeCapacities::default(),
on_ready: None,
commands: Vec::new(),
}
}
}
impl VictauriBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
#[must_use]
pub fn event_capacity(mut self, capacity: usize) -> Self {
self.event_capacity = capacity;
self
}
#[must_use]
pub fn recorder_capacity(mut self, capacity: usize) -> Self {
self.recorder_capacity = capacity;
self
}
#[must_use]
pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
self.eval_timeout = timeout;
self
}
#[must_use]
pub fn auth_token(mut self, token: impl Into<String>) -> Self {
self.auth_token = Some(token.into());
self
}
#[must_use]
pub fn generate_auth_token(mut self) -> Self {
self.auth_token = Some(auth::generate_token());
self
}
#[must_use]
pub fn auth_disabled(mut self) -> Self {
self.auth_explicitly_disabled = true;
self.auth_token = None;
self
}
#[must_use]
pub fn disable_tools(mut self, tools: &[&str]) -> Self {
self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
self
}
#[must_use]
pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
self.command_allowlist = Some(
commands
.iter()
.map(std::string::ToString::to_string)
.collect(),
);
self
}
#[must_use]
pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
self.command_blocklist = commands
.iter()
.map(std::string::ToString::to_string)
.collect();
self
}
#[must_use]
pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
self.redaction_patterns.push(pattern.into());
self
}
#[must_use]
pub fn enable_redaction(mut self) -> Self {
self.redaction_enabled = true;
self
}
#[must_use]
pub fn strict_privacy_mode(mut self) -> Self {
self.strict_privacy = true;
self
}
#[must_use]
pub fn console_log_capacity(mut self, capacity: usize) -> Self {
self.bridge_capacities.console_logs = capacity;
self
}
#[must_use]
pub fn network_log_capacity(mut self, capacity: usize) -> Self {
self.bridge_capacities.network_log = capacity;
self
}
#[must_use]
pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
self.bridge_capacities.navigation_log = capacity;
self
}
#[must_use]
pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
self.commands = schemas.to_vec();
self
}
#[must_use]
pub fn auto_discover(mut self) -> Self {
self.commands
.extend(victauri_core::auto_discovered_commands());
self
}
#[must_use]
pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
self.on_ready = Some(Box::new(f));
self
}
fn resolve_port(&self) -> u16 {
self.port
.or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
.unwrap_or(DEFAULT_PORT)
}
fn resolve_auth_token(&self) -> Option<String> {
if self.auth_explicitly_disabled {
return None;
}
self.auth_token
.clone()
.or_else(|| std::env::var("VICTAURI_AUTH_TOKEN").ok())
.or_else(|| Some(auth::generate_token()))
}
fn resolve_eval_timeout(&self) -> std::time::Duration {
std::env::var("VICTAURI_EVAL_TIMEOUT")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map_or(self.eval_timeout, std::time::Duration::from_secs)
}
fn build_privacy_config(&self) -> privacy::PrivacyConfig {
if self.strict_privacy {
let mut config = privacy::strict_privacy_config();
for cmd in &self.command_blocklist {
config.command_blocklist.insert(cmd.clone());
}
if let Some(ref allow) = self.command_allowlist {
config.command_allowlist = Some(allow.iter().cloned().collect());
}
for tool in &self.disabled_tools {
config.disabled_tools.insert(tool.clone());
}
if !self.redaction_patterns.is_empty() {
config.redactor = redaction::Redactor::new(&self.redaction_patterns);
}
config
} else {
privacy::PrivacyConfig {
command_allowlist: self
.command_allowlist
.as_ref()
.map(|v| v.iter().cloned().collect::<HashSet<String>>()),
command_blocklist: self.command_blocklist.iter().cloned().collect(),
disabled_tools: self.disabled_tools.iter().cloned().collect(),
redactor: redaction::Redactor::new(&self.redaction_patterns),
redaction_enabled: self.redaction_enabled,
}
}
}
fn validate(&self) -> Result<(), BuilderError> {
let port = self.resolve_port();
if port == 0 {
return Err(BuilderError::InvalidPort {
port,
reason: "port 0 is reserved".to_string(),
});
}
if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
return Err(BuilderError::InvalidEventCapacity {
capacity: self.event_capacity,
reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
});
}
if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
return Err(BuilderError::InvalidRecorderCapacity {
capacity: self.recorder_capacity,
reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
});
}
let timeout = self.resolve_eval_timeout();
if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
return Err(BuilderError::InvalidEvalTimeout {
timeout_secs: timeout.as_secs(),
reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
});
}
Ok(())
}
pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
#[cfg(not(debug_assertions))]
{
Ok(Builder::new("victauri").build())
}
#[cfg(debug_assertions)]
{
self.validate()?;
let port = self.resolve_port();
let event_capacity = self.event_capacity;
let recorder_capacity = self.recorder_capacity;
let eval_timeout = self.resolve_eval_timeout();
let auth_token = self.resolve_auth_token();
let privacy_config = self.build_privacy_config();
let on_ready = self.on_ready;
let commands = self.commands;
let js_init = js_bridge::init_script(&self.bridge_capacities);
Ok(Builder::new("victauri")
.setup(move |app, _api| {
let event_log = EventLog::new(event_capacity);
let registry = CommandRegistry::new();
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let state = Arc::new(VictauriState {
event_log,
registry,
port: AtomicU16::new(port),
pending_evals: Arc::new(Mutex::new(HashMap::new())),
recorder: EventRecorder::new(recorder_capacity),
privacy: privacy_config,
eval_timeout,
shutdown_tx,
started_at: std::time::Instant::now(),
tool_invocations: AtomicU64::new(0),
});
app.manage(state.clone());
for cmd in commands {
state.registry.register(cmd);
}
if let Some(ref token) = auth_token {
tracing::info!(
"Victauri MCP server auth enabled — token: {token}"
);
} else {
tracing::warn!(
"Victauri MCP server auth DISABLED — any localhost process can access the MCP server"
);
}
let app_handle = app.clone();
let ready_state = state.clone();
tauri::async_runtime::spawn(async move {
match mcp::start_server_with_options(
app_handle, state, port, auth_token, shutdown_rx,
)
.await
{
Ok(()) => {
tracing::info!("Victauri MCP server stopped");
}
Err(e) => {
tracing::error!("Victauri MCP server failed: {e}");
}
}
});
if let Some(cb) = on_ready {
tauri::async_runtime::spawn(async move {
for _ in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
if tokio::net::TcpStream::connect(format!(
"127.0.0.1:{actual_port}"
))
.await
.is_ok()
{
cb(actual_port);
return;
}
}
let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
tracing::warn!("Victauri on_ready: server did not become ready within 5s");
cb(actual_port);
});
}
tracing::info!("Victauri plugin initialized — MCP server on port {port}");
Ok(())
})
.on_event(|app, event| {
if let RunEvent::Exit = event
&& let Some(state) = app.try_state::<Arc<VictauriState>>()
{
let _ = state.shutdown_tx.send(true);
tracing::info!("Victauri shutdown signal sent");
}
})
.js_init_script(js_init)
.invoke_handler(tauri::generate_handler![
tools::victauri_eval_js,
tools::victauri_eval_callback,
tools::victauri_get_window_state,
tools::victauri_list_windows,
tools::victauri_get_ipc_log,
tools::victauri_get_registry,
tools::victauri_get_memory_stats,
tools::victauri_dom_snapshot,
tools::victauri_verify_state,
tools::victauri_detect_ghost_commands,
tools::victauri_check_ipc_integrity,
])
.build())
}
}
}
#[must_use]
pub fn init<R: Runtime>() -> TauriPlugin<R> {
VictauriBuilder::new()
.build()
.expect("default Victauri configuration is always valid")
}
#[must_use]
pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
VictauriBuilder::new()
.auto_discover()
.build()
.expect("default Victauri configuration is always valid")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_default_values() {
let builder = VictauriBuilder::new();
assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
assert!(builder.auth_token.is_none());
assert!(!builder.auth_explicitly_disabled);
let resolved = builder.resolve_auth_token();
assert!(resolved.is_some(), "auth should be enabled by default");
assert_eq!(
resolved.unwrap().len(),
36,
"auto-generated token should be a UUID"
);
assert!(builder.disabled_tools.is_empty());
assert!(builder.command_allowlist.is_none());
assert!(builder.command_blocklist.is_empty());
assert!(!builder.redaction_enabled);
assert!(!builder.strict_privacy);
}
#[test]
fn builder_port_override() {
let builder = VictauriBuilder::new().port(9090);
assert_eq!(builder.resolve_port(), 9090);
}
#[test]
#[allow(unsafe_code)]
fn builder_default_port() {
let builder = VictauriBuilder::new();
unsafe { std::env::remove_var("VICTAURI_PORT") };
assert_eq!(builder.resolve_port(), DEFAULT_PORT);
}
#[test]
fn builder_auth_token_explicit() {
let builder = VictauriBuilder::new().auth_token("my-secret");
assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
}
#[test]
fn builder_auth_token_generated() {
let builder = VictauriBuilder::new().generate_auth_token();
let token = builder.resolve_auth_token().unwrap();
assert_eq!(token.len(), 36);
}
#[test]
fn builder_auth_disabled() {
let builder = VictauriBuilder::new().auth_disabled();
assert!(builder.auth_explicitly_disabled);
assert!(
builder.resolve_auth_token().is_none(),
"auth_disabled should opt out of auto-generated token"
);
}
#[test]
fn builder_auth_disabled_overrides_explicit_token() {
let builder = VictauriBuilder::new()
.auth_token("my-secret")
.auth_disabled();
assert!(
builder.resolve_auth_token().is_none(),
"auth_disabled should override explicit token"
);
}
#[test]
fn builder_capacities() {
let builder = VictauriBuilder::new()
.event_capacity(500)
.recorder_capacity(2000);
assert_eq!(builder.event_capacity, 500);
assert_eq!(builder.recorder_capacity, 2000);
}
#[test]
fn builder_disable_tools() {
let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
assert_eq!(builder.disabled_tools.len(), 2);
assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
}
#[test]
fn builder_command_allowlist() {
let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
assert!(builder.command_allowlist.is_some());
assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
}
#[test]
fn builder_command_blocklist() {
let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
assert_eq!(builder.command_blocklist.len(), 1);
}
#[test]
fn builder_redaction() {
let builder = VictauriBuilder::new()
.add_redaction_pattern(r"SECRET_\w+")
.enable_redaction();
assert!(builder.redaction_enabled);
assert_eq!(builder.redaction_patterns.len(), 1);
}
#[test]
fn builder_strict_privacy_config() {
let builder = VictauriBuilder::new().strict_privacy_mode();
let config = builder.build_privacy_config();
assert!(config.redaction_enabled);
assert!(!config.disabled_tools.is_empty());
assert!(config.disabled_tools.contains("eval_js"));
assert!(config.disabled_tools.contains("screenshot"));
}
#[test]
fn builder_normal_privacy_config() {
let builder = VictauriBuilder::new()
.command_blocklist(&["secret_cmd"])
.disable_tools(&["eval_js"]);
let config = builder.build_privacy_config();
assert!(config.command_blocklist.contains("secret_cmd"));
assert!(config.disabled_tools.contains("eval_js"));
assert!(!config.redaction_enabled);
}
#[test]
fn builder_strict_with_extra_blocklist() {
let builder = VictauriBuilder::new()
.strict_privacy_mode()
.command_blocklist(&["extra_dangerous"]);
let config = builder.build_privacy_config();
assert!(config.command_blocklist.contains("extra_dangerous"));
assert!(config.disabled_tools.contains("eval_js"));
}
#[test]
fn builder_bridge_capacities() {
let builder = VictauriBuilder::new()
.console_log_capacity(5000)
.network_log_capacity(2000)
.navigation_log_capacity(500);
assert_eq!(builder.bridge_capacities.console_logs, 5000);
assert_eq!(builder.bridge_capacities.network_log, 2000);
assert_eq!(builder.bridge_capacities.navigation_log, 500);
assert_eq!(builder.bridge_capacities.mutation_log, 500);
assert_eq!(builder.bridge_capacities.dialog_log, 100);
}
#[test]
fn builder_on_ready_sets_callback() {
let builder = VictauriBuilder::new().on_ready(|_port| {});
assert!(builder.on_ready.is_some());
}
#[test]
fn init_script_contains_custom_capacities() {
let caps = js_bridge::BridgeCapacities {
console_logs: 3000,
mutation_log: 750,
network_log: 5000,
navigation_log: 400,
dialog_log: 250,
long_tasks: 200,
};
let script = js_bridge::init_script(&caps);
assert!(script.contains("CAP_CONSOLE = 3000"));
assert!(script.contains("CAP_MUTATION = 750"));
assert!(script.contains("CAP_NETWORK = 5000"));
assert!(script.contains("CAP_NAVIGATION = 400"));
assert!(script.contains("CAP_DIALOG = 250"));
assert!(script.contains("CAP_LONG_TASKS = 200"));
}
#[test]
fn init_script_default_contains_standard_capacities() {
let caps = js_bridge::BridgeCapacities::default();
let script = js_bridge::init_script(&caps);
assert!(script.contains("CAP_CONSOLE = 1000"));
assert!(script.contains("CAP_NETWORK = 1000"));
assert!(script.contains("window.__VICTAURI__"));
}
#[test]
fn builder_validates_defaults() {
let builder = VictauriBuilder::new();
assert!(builder.validate().is_ok());
}
#[test]
fn builder_rejects_zero_port() {
let builder = VictauriBuilder::new().port(0);
let err = builder.validate().unwrap_err();
assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
}
#[test]
fn builder_rejects_zero_event_capacity() {
let builder = VictauriBuilder::new().event_capacity(0);
let err = builder.validate().unwrap_err();
assert!(matches!(
err,
BuilderError::InvalidEventCapacity { capacity: 0, .. }
));
}
#[test]
fn builder_rejects_excessive_event_capacity() {
let builder = VictauriBuilder::new().event_capacity(2_000_000);
assert!(builder.validate().is_err());
}
#[test]
fn builder_rejects_zero_recorder_capacity() {
let builder = VictauriBuilder::new().recorder_capacity(0);
assert!(builder.validate().is_err());
}
#[test]
fn builder_rejects_zero_eval_timeout() {
let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
assert!(builder.validate().is_err());
}
#[test]
fn builder_rejects_excessive_eval_timeout() {
let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
assert!(builder.validate().is_err());
}
#[test]
fn builder_accepts_edge_values() {
let builder = VictauriBuilder::new()
.port(1)
.event_capacity(1)
.recorder_capacity(1)
.eval_timeout(std::time::Duration::from_secs(1));
assert!(builder.validate().is_ok());
let builder = VictauriBuilder::new()
.port(65535)
.event_capacity(MAX_EVENT_CAPACITY)
.recorder_capacity(MAX_RECORDER_CAPACITY)
.eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
assert!(builder.validate().is_ok());
}
}