1mod backend_params;
2mod compound_params;
3mod helpers;
4mod introspection_params;
5mod other_params;
6mod rest;
7mod server;
8mod verification_params;
9mod webview_params;
10mod window_params;
11
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16use rmcp::handler::server::tool::ToolCallContext;
17use rmcp::handler::server::wrapper::Parameters;
18use rmcp::model::{
19 AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
20 ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
21 ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
22 Tool, UnsubscribeRequestParams,
23};
24use rmcp::service::RequestContext;
25use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
26use tokio::sync::Mutex;
27
28use crate::VictauriState;
29use crate::bridge::WebviewBridge;
30
31use helpers::{
32 js_string, json_result, missing_param, sanitize_css_color, tool_disabled, tool_error,
33 validate_url,
34};
35
36pub use backend_params::*;
37pub use compound_params::*;
38pub use introspection_params::*;
39pub use other_params::{
40 DiagnosticsParams, FindElementsParams, ResolveCommandParams, SemanticAssertParams,
41 WaitCondition, WaitForParams,
42};
43pub use server::*;
44pub use verification_params::*;
45pub use webview_params::*;
46pub use window_params::*;
47
48pub(crate) const MAX_PENDING_EVALS: usize = 100;
53
54fn chrono_now() -> String {
55 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
56}
57
58const MAX_EVAL_CODE_LEN: usize = 1_000_000;
60
61const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
62const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
63const RESOURCE_URI_STATE: &str = "victauri://state";
64
65const BRIDGE_VERSION: &str = "0.5.0";
66
67const SAFE_ENV_PREFIXES: &[&str] = &[
68 "PATH",
69 "HOME",
70 "USER",
71 "LANG",
72 "LC_",
73 "TERM",
74 "SHELL",
75 "DISPLAY",
76 "XDG_",
77 "TAURI_",
78 "VICTAURI_",
79 "RUST",
80 "CARGO",
81 "NODE_ENV",
82 "APPDATA",
83 "LOCALAPPDATA",
84 "USERPROFILE",
85 "TEMP",
86 "TMP",
87 "PROGRAMFILES",
88 "SYSTEMROOT",
89 "WINDIR",
90 "COMSPEC",
91 "OS",
92 "PROCESSOR_",
93 "NUMBER_OF_PROCESSORS",
94 "COMPUTERNAME",
95 "HOSTNAME",
96 "PWD",
97 "OLDPWD",
98 "SHLVL",
99 "LOGNAME",
100];
101
102#[derive(Clone)]
104pub struct VictauriMcpHandler {
105 state: Arc<VictauriState>,
106 bridge: Arc<dyn WebviewBridge>,
107 subscriptions: Arc<Mutex<HashSet<String>>>,
108 bridge_checked: Arc<AtomicBool>,
109}
110
111#[tool_router]
112impl VictauriMcpHandler {
113 #[tool(
116 description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
117 annotations(
118 read_only_hint = false,
119 destructive_hint = true,
120 idempotent_hint = false,
121 open_world_hint = false
122 )
123 )]
124 async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
125 if !self.state.privacy.is_tool_enabled("eval_js") {
126 return tool_disabled("eval_js");
127 }
128 if params.code.len() > MAX_EVAL_CODE_LEN {
129 return tool_error("code exceeds maximum length (1 MB)");
130 }
131 match self
132 .eval_with_return(¶ms.code, params.webview_label.as_deref())
133 .await
134 {
135 Ok(result) => {
136 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
137 && let Some(err_val) = parsed.get("__error")
138 {
139 let msg = err_val.as_str().unwrap_or("unknown error");
140 return tool_error(format!("JavaScript error: {msg}"));
141 }
142 CallToolResult::success(vec![Content::text(result)])
143 }
144 Err(e) => tool_error(e),
145 }
146 }
147
148 #[tool(
149 description = "Get the DOM snapshot with stable ref handles. Default: compact accessible text (70-80%% fewer tokens). Set format=\"json\" for full tree. Returns tree + stale_refs (refs invalidated since last snapshot).",
150 annotations(
151 read_only_hint = true,
152 destructive_hint = false,
153 idempotent_hint = true,
154 open_world_hint = false
155 )
156 )]
157 async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
158 let format = params.format.unwrap_or(SnapshotFormat::Compact);
159 let format_str = match format {
160 SnapshotFormat::Compact => "compact",
161 SnapshotFormat::Json => "json",
162 };
163 let code = format!(
164 "return window.__VICTAURI__?.snapshot({})",
165 js_string(format_str)
166 );
167 self.eval_bridge(&code, params.webview_label.as_deref())
168 .await
169 }
170
171 #[tool(
172 description = "Search for elements by text, role, test_id, CSS selector (via `css` or `selector` param), or accessible name without a full snapshot. Returns lightweight matches with ref handles.",
173 annotations(
174 read_only_hint = true,
175 destructive_hint = false,
176 idempotent_hint = true,
177 open_world_hint = false
178 )
179 )]
180 async fn find_elements(
181 &self,
182 Parameters(params): Parameters<FindElementsParams>,
183 ) -> CallToolResult {
184 let mut parts: Vec<String> = Vec::new();
185 if let Some(t) = ¶ms.text {
186 parts.push(format!("text: {}", js_string(t)));
187 }
188 if let Some(r) = ¶ms.role {
189 parts.push(format!("role: {}", js_string(r)));
190 }
191 if let Some(tid) = ¶ms.test_id {
192 parts.push(format!("test_id: {}", js_string(tid)));
193 }
194 if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
195 parts.push(format!("css: {}", js_string(c)));
196 }
197 if let Some(n) = ¶ms.name {
198 parts.push(format!("name: {}", js_string(n)));
199 }
200 if let Some(max) = params.max_results {
201 parts.push(format!("max_results: {max}"));
202 }
203 if let Some(t) = ¶ms.tag {
204 parts.push(format!("tag: {}", js_string(t)));
205 }
206 if let Some(p) = ¶ms.placeholder {
207 parts.push(format!("placeholder: {}", js_string(p)));
208 }
209 if let Some(a) = ¶ms.alt {
210 parts.push(format!("alt: {}", js_string(a)));
211 }
212 if let Some(ta) = ¶ms.title_attr {
213 parts.push(format!("title_attr: {}", js_string(ta)));
214 }
215 if let Some(l) = ¶ms.label {
216 parts.push(format!("label: {}", js_string(l)));
217 }
218 if let Some(true) = params.exact {
219 parts.push("exact: true".to_string());
220 }
221 if let Some(e) = params.enabled {
222 parts.push(format!("enabled: {e}"));
223 }
224 let code = format!(
225 "return window.__VICTAURI__?.findElements({{ {} }})",
226 parts.join(", ")
227 );
228 match self
229 .eval_with_return(&code, params.webview_label.as_deref())
230 .await
231 {
232 Ok(result) => {
233 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
234 && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
235 {
236 return tool_error(err);
237 }
238 CallToolResult::success(vec![Content::text(result)])
239 }
240 Err(e) => tool_error(e),
241 }
242 }
243
244 #[tool(
245 description = "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Subject to privacy command filtering.",
246 annotations(
247 read_only_hint = false,
248 destructive_hint = true,
249 idempotent_hint = false,
250 open_world_hint = false
251 )
252 )]
253 async fn invoke_command(
254 &self,
255 Parameters(params): Parameters<InvokeCommandParams>,
256 ) -> CallToolResult {
257 if !self.state.privacy.is_invoke_allowed(¶ms.command) {
258 return tool_disabled("invoke_command");
259 }
260 if !self.state.privacy.is_command_allowed(¶ms.command) {
261 return tool_error(format!(
262 "command '{}' is blocked by privacy configuration",
263 params.command
264 ));
265 }
266
267 if let Some(fault) = self.state.fault_registry.check_and_trigger(¶ms.command) {
269 match fault {
270 crate::introspection::FaultType::Delay { delay_ms } => {
271 tracing::info!(
272 command = %params.command,
273 delay_ms = delay_ms,
274 "fault injection: delaying command"
275 );
276 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
277 }
279 crate::introspection::FaultType::Error { ref message } => {
280 tracing::info!(
281 command = %params.command,
282 "fault injection: returning error"
283 );
284 return tool_error(format!(
285 "[FAULT INJECTED] command '{}': {message}",
286 params.command
287 ));
288 }
289 crate::introspection::FaultType::Drop => {
290 tracing::info!(
291 command = %params.command,
292 "fault injection: dropping response"
293 );
294 return CallToolResult::success(vec![Content::text("{}")]);
295 }
296 crate::introspection::FaultType::Corrupt => {
297 tracing::info!(
298 command = %params.command,
299 "fault injection: corrupting response"
300 );
301 let args_json = params.args.unwrap_or(serde_json::json!({}));
303 let args_str =
304 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
305 let code = format!(
306 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
307 js_string(¶ms.command)
308 );
309 if let Ok(result) = self
310 .eval_with_return(&code, params.webview_label.as_deref())
311 .await
312 {
313 let corrupted = format!(
314 "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
315 result.len()
316 );
317 return CallToolResult::success(vec![Content::text(corrupted)]);
318 }
319 return CallToolResult::success(vec![Content::text(
320 "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
321 )]);
322 }
323 }
324 }
325
326 let start = std::time::Instant::now();
328 let args_json = params.args.unwrap_or(serde_json::json!({}));
329 let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
330 let code = format!(
331 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
332 js_string(¶ms.command)
333 );
334 let result = self
335 .eval_with_return(&code, params.webview_label.as_deref())
336 .await;
337 let elapsed = start.elapsed();
338 self.state.command_timings.record(¶ms.command, elapsed);
339
340 match result {
341 Ok(result) => {
342 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
343 && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
344 {
345 return tool_error(format!(
346 "command '{}' returned error: {err}",
347 params.command
348 ));
349 }
350 CallToolResult::success(vec![Content::text(result)])
351 }
352 Err(e) => tool_error(format!("invoke_command failed: {e}")),
353 }
354 }
355
356 #[tool(
357 description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
358 annotations(
359 read_only_hint = true,
360 destructive_hint = false,
361 idempotent_hint = true,
362 open_world_hint = false
363 )
364 )]
365 async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
366 self.track_tool_call();
367 if !self.state.privacy.is_tool_enabled("screenshot") {
368 return tool_disabled("screenshot");
369 }
370 match self
371 .bridge
372 .get_native_handle(params.window_label.as_deref())
373 {
374 Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
375 Ok(png_bytes) => {
376 use base64::Engine;
377 let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
378 CallToolResult::success(vec![Content::image(b64, "image/png")])
379 }
380 Err(e) => tool_error(format!("screenshot capture failed: {e}")),
381 },
382 Err(e) => tool_error(format!("cannot get window handle: {e}")),
383 }
384 }
385
386 #[tool(
387 description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
388 annotations(
389 read_only_hint = true,
390 destructive_hint = false,
391 idempotent_hint = true,
392 open_world_hint = false
393 )
394 )]
395 async fn verify_state(
396 &self,
397 Parameters(params): Parameters<VerifyStateParams>,
398 ) -> CallToolResult {
399 if !self.state.privacy.is_tool_enabled("eval_js") {
400 return tool_disabled("verify_state requires eval_js capability");
401 }
402 let code = format!("return ({})", params.frontend_expr);
403 let frontend_json = match self
404 .eval_with_return(&code, params.webview_label.as_deref())
405 .await
406 {
407 Ok(result) => result,
408 Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
409 };
410
411 let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
412 Ok(v) => v,
413 Err(e) => {
414 return tool_error(format!(
415 "frontend expression did not return valid JSON: {e}"
416 ));
417 }
418 };
419
420 let backend_state = if let Some(state) = params.backend_state {
421 state
422 } else if let Some(ref cmd) = params.backend_command {
423 if !self.state.privacy.is_command_allowed(cmd) {
424 return tool_error(format!(
425 "command '{cmd}' is blocked by privacy configuration"
426 ));
427 }
428 let args = params.backend_args.unwrap_or(serde_json::json!({}));
429 let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
430 let invoke_code = format!(
431 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
432 js_string(cmd)
433 );
434 match self
435 .eval_with_return(&invoke_code, params.webview_label.as_deref())
436 .await
437 {
438 Ok(result) => match serde_json::from_str(&result) {
439 Ok(v) => v,
440 Err(e) => {
441 return tool_error(format!(
442 "backend command '{cmd}' did not return valid JSON: {e}"
443 ));
444 }
445 },
446 Err(e) => {
447 return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
448 }
449 }
450 } else {
451 return tool_error("either backend_state or backend_command must be provided");
452 };
453
454 let result = victauri_core::verify_state(frontend_state, backend_state);
455 json_result(&result)
456 }
457
458 #[tool(
459 description = "Detect ghost commands — commands invoked from the frontend that have no backend handler, or registered backend commands never called. Reads from the JS-side IPC interception log.",
460 annotations(
461 read_only_hint = true,
462 destructive_hint = false,
463 idempotent_hint = true,
464 open_world_hint = false
465 )
466 )]
467 async fn detect_ghost_commands(
468 &self,
469 Parameters(params): Parameters<GhostCommandParams>,
470 ) -> CallToolResult {
471 let code = "return window.__VICTAURI__?.getIpcLog()";
472 let ipc_json = match self
473 .eval_with_return(code, params.webview_label.as_deref())
474 .await
475 {
476 Ok(r) => r,
477 Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
478 };
479
480 let ipc_calls: Vec<serde_json::Value> = match serde_json::from_str(&ipc_json) {
481 Ok(v) => v,
482 Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
483 };
484 let frontend_commands: Vec<String> = ipc_calls
485 .iter()
486 .filter_map(|c| c.get("command").and_then(|v| v.as_str()).map(String::from))
487 .collect::<std::collections::HashSet<_>>()
488 .into_iter()
489 .collect();
490
491 let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
492 json_result(&report)
493 }
494
495 #[tool(
496 description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
497 annotations(
498 read_only_hint = true,
499 destructive_hint = false,
500 idempotent_hint = true,
501 open_world_hint = false
502 )
503 )]
504 async fn check_ipc_integrity(
505 &self,
506 Parameters(params): Parameters<IpcIntegrityParams>,
507 ) -> CallToolResult {
508 let threshold = params.stale_threshold_ms.unwrap_or(5000);
509 let code = format!(
510 r"return (function() {{
511 var log = window.__VICTAURI__?.getIpcLog() || [];
512 var now = Date.now();
513 var threshold = {threshold};
514 var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
515 var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
516 var errored = log.filter(function(c) {{ return c.status === 'error'; }});
517 return {{
518 healthy: stale.length === 0 && errored.length === 0,
519 total_calls: log.length,
520 pending_count: pending.length,
521 stale_count: stale.length,
522 error_count: errored.length,
523 stale_calls: stale.slice(0, 20),
524 errored_calls: errored.slice(0, 20)
525 }};
526 }})()"
527 );
528 self.eval_bridge(&code, params.webview_label.as_deref())
529 .await
530 }
531
532 #[tool(
533 description = "Wait for a condition to be met. Polls at regular intervals until satisfied or timeout. Conditions: text (text appears), text_gone (text disappears), selector (CSS selector matches), selector_gone, url (URL contains value), ipc_idle (no pending IPC calls), network_idle (no pending network requests).",
534 annotations(
535 read_only_hint = true,
536 destructive_hint = false,
537 idempotent_hint = true,
538 open_world_hint = false
539 )
540 )]
541 async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
542 let value = params
543 .value
544 .as_ref()
545 .map_or_else(|| "null".to_string(), |v| js_string(v));
546 let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(60_000);
547 let poll = params.poll_ms.unwrap_or(200);
548 let code = format!(
549 "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
550 js_string(params.condition.as_str())
551 );
552 let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
553 match self
554 .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
555 .await
556 {
557 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
558 Err(e) => tool_error(e),
559 }
560 }
561
562 #[tool(
563 description = "Run a semantic assertion: evaluate a JS expression and check the result against an expected condition. Conditions: equals, not_equals, contains, greater_than, less_than, truthy, falsy, exists, type_is.",
564 annotations(
565 read_only_hint = true,
566 destructive_hint = false,
567 idempotent_hint = true,
568 open_world_hint = false
569 )
570 )]
571 async fn assert_semantic(
572 &self,
573 Parameters(params): Parameters<SemanticAssertParams>,
574 ) -> CallToolResult {
575 if !self.state.privacy.is_tool_enabled("eval_js") {
576 return tool_disabled("assert_semantic requires eval_js capability");
577 }
578 let code = format!("return ({})", params.expression);
579 let actual_json = match self
580 .eval_with_return(&code, params.webview_label.as_deref())
581 .await
582 {
583 Ok(result) => result,
584 Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
585 };
586
587 let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
588 Ok(v) => v,
589 Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
590 };
591
592 let assertion = victauri_core::SemanticAssertion {
593 label: params.label,
594 condition: params.condition,
595 expected: params.expected,
596 };
597
598 let result = victauri_core::evaluate_assertion(actual, &assertion);
599 json_result(&result)
600 }
601
602 #[tool(
603 description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
604 annotations(
605 read_only_hint = true,
606 destructive_hint = false,
607 idempotent_hint = true,
608 open_world_hint = false
609 )
610 )]
611 async fn resolve_command(
612 &self,
613 Parameters(params): Parameters<ResolveCommandParams>,
614 ) -> CallToolResult {
615 self.track_tool_call();
616 let limit = params.limit.unwrap_or(5);
617 let mut results = self.state.registry.resolve(¶ms.query);
618 results.truncate(limit);
619 json_result(&results)
620 }
621
622 #[tool(
623 description = "List or search all registered Tauri commands with their argument schemas. Pass query to filter by name/description substring. Commands are registered via #[inspectable] macro.",
624 annotations(
625 read_only_hint = true,
626 destructive_hint = false,
627 idempotent_hint = true,
628 open_world_hint = false
629 )
630 )]
631 async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
632 self.track_tool_call();
633 let commands = match params.query {
634 Some(q) => self.state.registry.search(&q),
635 None => self.state.registry.list(),
636 };
637 json_result(&commands)
638 }
639
640 #[tool(
641 description = "Get real-time process memory statistics from the OS (working set, page file usage). On Windows returns detailed metrics; on Linux returns virtual/resident size.",
642 annotations(
643 read_only_hint = true,
644 destructive_hint = false,
645 idempotent_hint = true,
646 open_world_hint = false
647 )
648 )]
649 async fn get_memory_stats(&self) -> CallToolResult {
650 self.track_tool_call();
651 let stats = crate::memory::current_stats();
652 json_result(&stats)
653 }
654
655 #[tool(
656 description = "Inspect the Victauri plugin's own configuration: port, enabled/disabled tools, command filters, privacy settings, capacities, and version. Useful for agents to understand their capabilities before acting.",
657 annotations(
658 read_only_hint = true,
659 destructive_hint = false,
660 idempotent_hint = true,
661 open_world_hint = false
662 )
663 )]
664 async fn get_plugin_info(&self) -> CallToolResult {
665 self.track_tool_call();
666 let disabled: Vec<&str> = self
667 .state
668 .privacy
669 .disabled_tools
670 .iter()
671 .map(std::string::String::as_str)
672 .collect();
673 let blocklist: Vec<&str> = self
674 .state
675 .privacy
676 .command_blocklist
677 .iter()
678 .map(std::string::String::as_str)
679 .collect();
680 let allowlist: Option<Vec<&str>> = self
681 .state
682 .privacy
683 .command_allowlist
684 .as_ref()
685 .map(|s| s.iter().map(std::string::String::as_str).collect());
686 let all_tools = Self::tool_router().list_all();
687 let enabled_tools: Vec<&str> = all_tools
688 .iter()
689 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
690 .map(|t| t.name.as_ref())
691 .collect();
692
693 let result = serde_json::json!({
694 "version": env!("CARGO_PKG_VERSION"),
695 "bridge_version": BRIDGE_VERSION,
696 "port": self.state.port.load(Ordering::Relaxed),
697 "tools": {
698 "total": all_tools.len(),
699 "enabled": enabled_tools.len(),
700 "enabled_list": enabled_tools,
701 "disabled_list": disabled,
702 },
703 "commands": {
704 "allowlist": allowlist,
705 "blocklist": blocklist,
706 },
707 "privacy": {
708 "profile": self.state.privacy.profile.to_string(),
709 "redaction_enabled": self.state.privacy.redaction_enabled,
710 },
711 "capacities": {
712 "event_log": self.state.event_log.capacity(),
713 "eval_timeout_secs": self.state.eval_timeout.as_secs(),
714 },
715 "registered_commands": self.state.registry.count(),
716 "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
717 "uptime_secs": self.state.started_at.elapsed().as_secs(),
718 });
719 json_result(&result)
720 }
721
722 #[tool(
723 description = "Run environment diagnostics: detect service workers (break IPC interception), closed shadow DOM (invisible to snapshots), iframes (bridge absent), large DOM warnings, and CSP status. Call this first when connecting to an unfamiliar app.",
724 annotations(
725 read_only_hint = true,
726 destructive_hint = false,
727 idempotent_hint = true,
728 open_world_hint = false
729 )
730 )]
731 async fn get_diagnostics(
732 &self,
733 Parameters(params): Parameters<DiagnosticsParams>,
734 ) -> CallToolResult {
735 self.eval_bridge(
736 "return window.__VICTAURI__?.getDiagnostics()",
737 params.webview_label.as_deref(),
738 )
739 .await
740 }
741
742 #[tool(
745 description = "Get comprehensive app info: Tauri config (identifier, product name, version), app directory paths (data, config, log, local_data), process environment variables, and database files found in app directories. Provides direct backend context without going through the webview.",
746 annotations(
747 read_only_hint = true,
748 destructive_hint = false,
749 idempotent_hint = true,
750 open_world_hint = false
751 )
752 )]
753 async fn app_info(&self) -> CallToolResult {
754 self.track_tool_call();
755 let config = self.bridge.tauri_config();
756
757 let data_dir = self.bridge.app_data_dir().ok();
758 let config_dir = self.bridge.app_config_dir().ok();
759 let log_dir = self.bridge.app_log_dir().ok();
760 let local_data_dir = self.bridge.app_local_data_dir().ok();
761
762 let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
763 .filter(|(k, _)| {
764 let upper = k.to_uppercase();
765 SAFE_ENV_PREFIXES
766 .iter()
767 .any(|prefix| upper.starts_with(prefix))
768 })
769 .collect();
770
771 #[cfg(feature = "sqlite")]
772 let databases: Vec<String> = data_dir
773 .as_ref()
774 .map(|d| {
775 crate::database::discover_databases(d)
776 .into_iter()
777 .filter_map(|p| {
778 p.strip_prefix(d)
779 .ok()
780 .map(|rel| rel.to_string_lossy().into_owned())
781 })
782 .collect()
783 })
784 .unwrap_or_default();
785
786 #[cfg(not(feature = "sqlite"))]
787 let databases: Vec<String> = Vec::new();
788
789 let result = serde_json::json!({
790 "config": config,
791 "paths": {
792 "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
793 "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
794 "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
795 "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
796 },
797 "databases": databases,
798 "env": env_vars,
799 "process": {
800 "pid": std::process::id(),
801 "arch": std::env::consts::ARCH,
802 "os": std::env::consts::OS,
803 "family": std::env::consts::FAMILY,
804 },
805 });
806 json_result(&result)
807 }
808
809 #[tool(
810 description = "List files in the app's data, config, log, or local_data directories. Useful for discovering databases, config files, logs, and cached data on the backend — without going through the webview.",
811 annotations(
812 read_only_hint = true,
813 destructive_hint = false,
814 idempotent_hint = true,
815 open_world_hint = false
816 )
817 )]
818 async fn list_app_dir(
819 &self,
820 Parameters(params): Parameters<ListAppDirParams>,
821 ) -> CallToolResult {
822 self.track_tool_call();
823 let base = match self.resolve_app_dir(params.directory) {
824 Ok(d) => d,
825 Err(e) => return tool_error(e),
826 };
827
828 let target = if let Some(ref sub) = params.path {
829 let resolved = base.join(sub);
830 if !resolved.exists() {
831 return tool_error(format!("directory does not exist: {}", resolved.display()));
832 }
833 if let Err(e) = Self::safe_within(&base, &resolved) {
834 return tool_error(e);
835 }
836 resolved
837 } else {
838 base.clone()
839 };
840
841 if !target.exists() {
842 return tool_error(format!("directory does not exist: {}", target.display()));
843 }
844
845 let max_depth = params.max_depth.unwrap_or(1).min(5);
846 let pattern = params.pattern.as_deref();
847 let mut entries = Vec::new();
848
849 Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
850
851 json_result(&serde_json::json!({
852 "base": base.to_string_lossy(),
853 "path": params.path.unwrap_or_default(),
854 "entries": entries,
855 "count": entries.len(),
856 }))
857 }
858
859 #[tool(
860 description = "Read a file from the app's data, config, log, or local_data directory. Returns UTF-8 text by default, or base64 for binary files. Directly reads backend files without going through the webview.",
861 annotations(
862 read_only_hint = true,
863 destructive_hint = false,
864 idempotent_hint = true,
865 open_world_hint = false
866 )
867 )]
868 async fn read_app_file(
869 &self,
870 Parameters(params): Parameters<ReadAppFileParams>,
871 ) -> CallToolResult {
872 self.track_tool_call();
873 let base = match self.resolve_app_dir(params.directory) {
874 Ok(d) => d,
875 Err(e) => return tool_error(e),
876 };
877
878 let target = base.join(¶ms.path);
879 if !target.exists() {
880 return tool_error(format!("file not found: {}", params.path));
881 }
882 if let Err(e) = Self::safe_within(&base, &target) {
883 return tool_error(e);
884 }
885 if !target.is_file() {
886 return tool_error(format!("not a file: {}", params.path));
887 }
888
889 let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
890 let metadata = std::fs::metadata(&target).map_err(|e| e.to_string());
891
892 match std::fs::read(&target) {
893 Ok(mut bytes) => {
894 let original_size = bytes.len();
895 let truncated = bytes.len() > max_bytes;
896 if truncated {
897 bytes.truncate(max_bytes);
898 }
899
900 let file_info = serde_json::json!({
901 "path": params.path,
902 "size": original_size,
903 "truncated": truncated,
904 "modified": metadata.as_ref().ok()
905 .and_then(|m| m.modified().ok())
906 .map(|t| {
907 let duration = t.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap_or_default();
908 duration.as_secs()
909 }),
910 });
911
912 if params.binary == Some(true) {
913 use base64::Engine;
914 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
915 json_result(&serde_json::json!({
916 "file": file_info,
917 "encoding": "base64",
918 "content": b64,
919 }))
920 } else {
921 match String::from_utf8(bytes) {
922 Ok(text) => json_result(&serde_json::json!({
923 "file": file_info,
924 "encoding": "utf-8",
925 "content": text,
926 })),
927 Err(e) => {
928 use base64::Engine;
929 let bytes = e.into_bytes();
930 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
931 json_result(&serde_json::json!({
932 "file": file_info,
933 "encoding": "base64",
934 "note": "file is not valid UTF-8, returning base64",
935 "content": b64,
936 }))
937 }
938 }
939 }
940 }
941 Err(e) => tool_error(format!("failed to read file: {e}")),
942 }
943 }
944
945 #[cfg(feature = "sqlite")]
946 #[tool(
947 description = "Execute a read-only SQL query against a SQLite database in the app's data directory. Auto-discovers database files if no path is specified. Only SELECT/PRAGMA/EXPLAIN/WITH queries are allowed. Returns rows as JSON objects with column names as keys. This provides direct backend database access without going through the webview or IPC.",
948 annotations(
949 read_only_hint = true,
950 destructive_hint = false,
951 idempotent_hint = true,
952 open_world_hint = false
953 )
954 )]
955 async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
956 self.track_tool_call();
957 let data_dir = match self.bridge.app_data_dir() {
958 Ok(d) => d,
959 Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
960 };
961
962 let db_path = if let Some(ref rel_path) = params.path {
963 let resolved = data_dir.join(rel_path);
964 if !resolved.exists() {
965 return tool_error(format!("database not found: {rel_path}"));
966 }
967 if let Err(e) = Self::safe_within(&data_dir, &resolved) {
968 return tool_error(e);
969 }
970 resolved
971 } else {
972 let databases = crate::database::discover_databases(&data_dir);
973 match databases.first() {
974 Some(p) => p.clone(),
975 None => {
976 return tool_error(format!(
977 "no SQLite databases found in {}",
978 data_dir.display()
979 ));
980 }
981 }
982 };
983
984 let db_display = db_path
985 .strip_prefix(&data_dir)
986 .unwrap_or(&db_path)
987 .to_string_lossy()
988 .into_owned();
989 let bind_params = params.params.unwrap_or_default();
990
991 match crate::database::query(&db_path, ¶ms.query, &bind_params, params.max_rows) {
992 Ok(mut result) => {
993 if let Some(obj) = result.as_object_mut() {
994 obj.insert("database".to_string(), serde_json::json!(db_display));
995 }
996 json_result(&result)
997 }
998 Err(e) => tool_error(e),
999 }
1000 }
1001
1002 #[tool(
1005 description = "DOM element interactions. Actions: click, double_click, hover, focus, scroll_into_view, select_option. Requires ref_id from a dom_snapshot for most actions.",
1006 annotations(
1007 read_only_hint = false,
1008 destructive_hint = false,
1009 idempotent_hint = false,
1010 open_world_hint = false
1011 )
1012 )]
1013 async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1014 if !self.state.privacy.is_tool_enabled("interact") {
1015 return tool_disabled("interact");
1016 }
1017 match params.action {
1018 InteractAction::Click => {
1019 if !self.state.privacy.is_tool_enabled("interact.click") {
1020 return tool_disabled("interact.click");
1021 }
1022 let Some(ref_id) = ¶ms.ref_id else {
1023 return missing_param("ref_id", "click");
1024 };
1025 let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1026 self.eval_bridge(&code, params.webview_label.as_deref())
1027 .await
1028 }
1029 InteractAction::DoubleClick => {
1030 if !self.state.privacy.is_tool_enabled("interact.double_click") {
1031 return tool_disabled("interact.double_click");
1032 }
1033 let Some(ref_id) = ¶ms.ref_id else {
1034 return missing_param("ref_id", "double_click");
1035 };
1036 let code = format!(
1037 "return window.__VICTAURI__?.doubleClick({})",
1038 js_string(ref_id)
1039 );
1040 self.eval_bridge(&code, params.webview_label.as_deref())
1041 .await
1042 }
1043 InteractAction::Hover => {
1044 if !self.state.privacy.is_tool_enabled("interact.hover") {
1045 return tool_disabled("interact.hover");
1046 }
1047 let Some(ref_id) = ¶ms.ref_id else {
1048 return missing_param("ref_id", "hover");
1049 };
1050 let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1051 self.eval_bridge(&code, params.webview_label.as_deref())
1052 .await
1053 }
1054 InteractAction::Focus => {
1055 if !self.state.privacy.is_tool_enabled("interact.focus") {
1056 return tool_disabled("interact.focus");
1057 }
1058 let Some(ref_id) = ¶ms.ref_id else {
1059 return missing_param("ref_id", "focus");
1060 };
1061 let code = format!(
1062 "return window.__VICTAURI__?.focusElement({})",
1063 js_string(ref_id)
1064 );
1065 self.eval_bridge(&code, params.webview_label.as_deref())
1066 .await
1067 }
1068 InteractAction::ScrollIntoView => {
1069 if !self
1070 .state
1071 .privacy
1072 .is_tool_enabled("interact.scroll_into_view")
1073 {
1074 return tool_disabled("interact.scroll_into_view");
1075 }
1076 let ref_arg = params
1077 .ref_id
1078 .as_ref()
1079 .map_or_else(|| "null".to_string(), |r| js_string(r));
1080 let x = params.x.unwrap_or(0.0);
1081 let y = params.y.unwrap_or(0.0);
1082 let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1083 self.eval_bridge(&code, params.webview_label.as_deref())
1084 .await
1085 }
1086 InteractAction::SelectOption => {
1087 if !self.state.privacy.is_tool_enabled("interact.select_option") {
1088 return tool_disabled("interact.select_option");
1089 }
1090 let Some(ref_id) = ¶ms.ref_id else {
1091 return missing_param("ref_id", "select_option");
1092 };
1093 let values = params.values.as_deref().unwrap_or(&[]);
1094 let values_json =
1095 serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1096 let code = format!(
1097 "return window.__VICTAURI__?.selectOption({}, {})",
1098 js_string(ref_id),
1099 values_json
1100 );
1101 self.eval_bridge(&code, params.webview_label.as_deref())
1102 .await
1103 }
1104 }
1105 }
1106
1107 #[tool(
1108 description = "Text and keyboard input. Actions: fill (set input value), type_text (character-by-character typing), press_key (trigger a keyboard key). Subject to privacy controls.",
1109 annotations(
1110 read_only_hint = false,
1111 destructive_hint = false,
1112 idempotent_hint = false,
1113 open_world_hint = false
1114 )
1115 )]
1116 async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1117 match params.action {
1118 InputAction::Fill => {
1119 if !self.state.privacy.is_tool_enabled("fill") {
1120 return tool_disabled("fill");
1121 }
1122 let Some(ref_id) = ¶ms.ref_id else {
1123 return missing_param("ref_id", "fill");
1124 };
1125 let Some(value) = ¶ms.value else {
1126 return missing_param("value", "fill");
1127 };
1128 let code = format!(
1129 "return window.__VICTAURI__?.fill({}, {})",
1130 js_string(ref_id),
1131 js_string(value)
1132 );
1133 self.eval_bridge(&code, params.webview_label.as_deref())
1134 .await
1135 }
1136 InputAction::TypeText => {
1137 if !self.state.privacy.is_tool_enabled("type_text") {
1138 return tool_disabled("type_text");
1139 }
1140 let Some(ref_id) = ¶ms.ref_id else {
1141 return missing_param("ref_id", "type_text");
1142 };
1143 let Some(text) = ¶ms.text else {
1144 return missing_param("text", "type_text");
1145 };
1146 let code = format!(
1147 "return window.__VICTAURI__?.type({}, {})",
1148 js_string(ref_id),
1149 js_string(text)
1150 );
1151 self.eval_bridge(&code, params.webview_label.as_deref())
1152 .await
1153 }
1154 InputAction::PressKey => {
1155 if !self.state.privacy.is_tool_enabled("input.press_key") {
1156 return tool_disabled("input.press_key");
1157 }
1158 let Some(key) = ¶ms.key else {
1159 return missing_param("key", "press_key");
1160 };
1161 let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1162 self.eval_bridge(&code, params.webview_label.as_deref())
1163 .await
1164 }
1165 }
1166 }
1167
1168 #[tool(
1169 description = "Window management. Actions: get_state (window positions/sizes/visibility), list (all window labels), manage (minimize/maximize/close/focus/show/hide/fullscreen/always_on_top), resize, move_to, set_title.",
1170 annotations(
1171 read_only_hint = false,
1172 destructive_hint = false,
1173 idempotent_hint = true,
1174 open_world_hint = false
1175 )
1176 )]
1177 async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1178 self.track_tool_call();
1179 match params.action {
1180 WindowAction::GetState => {
1181 let states = self.bridge.get_window_states(params.label.as_deref());
1182 json_result(&states)
1183 }
1184 WindowAction::List => {
1185 let labels = self.bridge.list_window_labels();
1186 json_result(&labels)
1187 }
1188 WindowAction::Manage => {
1189 if !self.state.privacy.is_tool_enabled("window.manage") {
1190 return tool_disabled("window.manage");
1191 }
1192 let Some(manage_action) = ¶ms.manage_action else {
1193 return missing_param("manage_action", "manage");
1194 };
1195 match self
1196 .bridge
1197 .manage_window(params.label.as_deref(), manage_action.as_str())
1198 {
1199 Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1200 Err(e) => tool_error(e),
1201 }
1202 }
1203 WindowAction::Resize => {
1204 if !self.state.privacy.is_tool_enabled("window.resize") {
1205 return tool_disabled("window.resize");
1206 }
1207 let Some(width) = params.width else {
1208 return missing_param("width", "resize");
1209 };
1210 let Some(height) = params.height else {
1211 return missing_param("height", "resize");
1212 };
1213 match self
1214 .bridge
1215 .resize_window(params.label.as_deref(), width, height)
1216 {
1217 Ok(()) => {
1218 let result =
1219 serde_json::json!({"ok": true, "width": width, "height": height});
1220 CallToolResult::success(vec![Content::text(result.to_string())])
1221 }
1222 Err(e) => tool_error(e),
1223 }
1224 }
1225 WindowAction::MoveTo => {
1226 if !self.state.privacy.is_tool_enabled("window.move_to") {
1227 return tool_disabled("window.move_to");
1228 }
1229 let Some(x) = params.x else {
1230 return missing_param("x", "move_to");
1231 };
1232 let Some(y) = params.y else {
1233 return missing_param("y", "move_to");
1234 };
1235 match self.bridge.move_window(params.label.as_deref(), x, y) {
1236 Ok(()) => {
1237 let result = serde_json::json!({"ok": true, "x": x, "y": y});
1238 CallToolResult::success(vec![Content::text(result.to_string())])
1239 }
1240 Err(e) => tool_error(e),
1241 }
1242 }
1243 WindowAction::SetTitle => {
1244 if !self.state.privacy.is_tool_enabled("window.set_title") {
1245 return tool_disabled("window.set_title");
1246 }
1247 let Some(title) = ¶ms.title else {
1248 return missing_param("title", "set_title");
1249 };
1250 match self.bridge.set_window_title(params.label.as_deref(), title) {
1251 Ok(()) => {
1252 let result = serde_json::json!({"ok": true, "title": title});
1253 CallToolResult::success(vec![Content::text(result.to_string())])
1254 }
1255 Err(e) => tool_error(e),
1256 }
1257 }
1258 }
1259 }
1260
1261 #[tool(
1262 description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1263 annotations(
1264 read_only_hint = false,
1265 destructive_hint = true,
1266 idempotent_hint = false,
1267 open_world_hint = false
1268 )
1269 )]
1270 async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1271 match params.action {
1272 StorageAction::Get => {
1273 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1274 StorageType::Session => "getSessionStorage",
1275 StorageType::Local => "getLocalStorage",
1276 };
1277 let key_arg = params
1278 .key
1279 .as_ref()
1280 .map(|k| js_string(k))
1281 .unwrap_or_default();
1282 let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1283 self.eval_bridge(&code, params.webview_label.as_deref())
1284 .await
1285 }
1286 StorageAction::Set => {
1287 if !self.state.privacy.is_tool_enabled("set_storage") {
1288 return tool_disabled("set_storage");
1289 }
1290 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1291 StorageType::Session => "setSessionStorage",
1292 StorageType::Local => "setLocalStorage",
1293 };
1294 let Some(key) = ¶ms.key else {
1295 return missing_param("key", "set");
1296 };
1297 let value = params
1298 .value
1299 .as_ref()
1300 .cloned()
1301 .unwrap_or(serde_json::Value::Null);
1302 let value_json =
1303 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1304 let code = format!(
1305 "return window.__VICTAURI__?.{method}({}, {value_json})",
1306 js_string(key)
1307 );
1308 self.eval_bridge(&code, params.webview_label.as_deref())
1309 .await
1310 }
1311 StorageAction::Delete => {
1312 if !self.state.privacy.is_tool_enabled("delete_storage") {
1313 return tool_disabled("delete_storage");
1314 }
1315 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1316 StorageType::Session => "deleteSessionStorage",
1317 StorageType::Local => "deleteLocalStorage",
1318 };
1319 let Some(key) = ¶ms.key else {
1320 return missing_param("key", "delete");
1321 };
1322 let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1323 self.eval_bridge(&code, params.webview_label.as_deref())
1324 .await
1325 }
1326 StorageAction::GetCookies => {
1327 self.eval_bridge(
1328 "return window.__VICTAURI__?.getCookies()",
1329 params.webview_label.as_deref(),
1330 )
1331 .await
1332 }
1333 }
1334 }
1335
1336 #[tool(
1337 description = "Navigation and dialog control. Actions: go_to (navigate to URL), go_back (browser back), get_history (navigation log), set_dialog_response (auto-respond to alert/confirm/prompt), get_dialog_log (captured dialog events). Subject to privacy controls for go_to and set_dialog_response.",
1338 annotations(
1339 read_only_hint = false,
1340 destructive_hint = false,
1341 idempotent_hint = false,
1342 open_world_hint = false
1343 )
1344 )]
1345 async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1346 match params.action {
1347 NavigateAction::GoTo => {
1348 if !self.state.privacy.is_tool_enabled("navigate") {
1349 return tool_disabled("navigate");
1350 }
1351 let Some(url) = ¶ms.url else {
1352 return missing_param("url", "go_to");
1353 };
1354 if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1355 return tool_error(e);
1356 }
1357 let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1358 self.eval_bridge(&code, params.webview_label.as_deref())
1359 .await
1360 }
1361 NavigateAction::GoBack => {
1362 self.eval_bridge(
1363 "return window.__VICTAURI__?.navigateBack()",
1364 params.webview_label.as_deref(),
1365 )
1366 .await
1367 }
1368 NavigateAction::GetHistory => {
1369 self.eval_bridge(
1370 "return window.__VICTAURI__?.getNavigationLog()",
1371 params.webview_label.as_deref(),
1372 )
1373 .await
1374 }
1375 NavigateAction::SetDialogResponse => {
1376 if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1377 return tool_disabled("set_dialog_response");
1378 }
1379 let Some(dialog_type) = params.dialog_type else {
1380 return missing_param("dialog_type", "set_dialog_response");
1381 };
1382 let Some(dialog_action) = params.dialog_action else {
1383 return missing_param("dialog_action", "set_dialog_response");
1384 };
1385 let text_arg = params
1386 .text
1387 .as_ref()
1388 .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1389 let code = format!(
1390 "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1391 js_string(dialog_type.as_str()),
1392 js_string(dialog_action.as_str())
1393 );
1394 self.eval_bridge(&code, params.webview_label.as_deref())
1395 .await
1396 }
1397 NavigateAction::GetDialogLog => {
1398 self.eval_bridge(
1399 "return window.__VICTAURI__?.getDialogLog()",
1400 params.webview_label.as_deref(),
1401 )
1402 .await
1403 }
1404 }
1405 }
1406
1407 #[tool(
1408 description = "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load session from JSON), replay (re-execute recorded IPC commands and compare responses).",
1409 annotations(
1410 read_only_hint = false,
1411 destructive_hint = false,
1412 idempotent_hint = false,
1413 open_world_hint = false
1414 )
1415 )]
1416 async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1417 const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1418 self.track_tool_call();
1419 if !self.state.privacy.is_tool_enabled("recording") {
1420 return tool_disabled("recording");
1421 }
1422 match params.action {
1423 RecordingAction::Start => {
1424 let session_id = params
1425 .session_id
1426 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1427 match self.state.recorder.start(session_id.clone()) {
1428 Ok(()) => {
1429 let result = serde_json::json!({
1430 "started": true,
1431 "session_id": session_id,
1432 });
1433 CallToolResult::success(vec![Content::text(result.to_string())])
1434 }
1435 Err(e) => tool_error(e.to_string()),
1436 }
1437 }
1438 RecordingAction::Stop => match self.state.recorder.stop() {
1439 Some(session) => json_result(&session),
1440 None => tool_error("no recording is active"),
1441 },
1442 RecordingAction::Checkpoint => {
1443 let Some(id) = params.checkpoint_id else {
1444 return missing_param("checkpoint_id", "checkpoint");
1445 };
1446 let state = params.state.unwrap_or(serde_json::Value::Null);
1447 match self
1448 .state
1449 .recorder
1450 .checkpoint(id.clone(), params.checkpoint_label, state)
1451 {
1452 Ok(()) => {
1453 let result = serde_json::json!({
1454 "created": true,
1455 "checkpoint_id": id,
1456 "event_index": self.state.recorder.event_count(),
1457 });
1458 CallToolResult::success(vec![Content::text(result.to_string())])
1459 }
1460 Err(e) => tool_error(e.to_string()),
1461 }
1462 }
1463 RecordingAction::ListCheckpoints => {
1464 let checkpoints = self.state.recorder.get_checkpoints();
1465 json_result(&checkpoints)
1466 }
1467 RecordingAction::GetEvents => {
1468 let events = self
1469 .state
1470 .recorder
1471 .events_since(params.since_index.unwrap_or(0));
1472 json_result(&events)
1473 }
1474 RecordingAction::EventsBetween => {
1475 let Some(from) = ¶ms.from else {
1476 return missing_param("from", "events_between");
1477 };
1478 let Some(to) = ¶ms.to else {
1479 return missing_param("to", "events_between");
1480 };
1481 match self.state.recorder.events_between_checkpoints(from, to) {
1482 Ok(events) => json_result(&events),
1483 Err(e) => tool_error(e.to_string()),
1484 }
1485 }
1486 RecordingAction::GetReplay => {
1487 let calls = self.state.recorder.ipc_replay_sequence();
1488 json_result(&calls)
1489 }
1490 RecordingAction::Export => match self.state.recorder.export() {
1491 Some(s) => {
1492 let json = serde_json::to_string_pretty(&s)
1493 .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1494 CallToolResult::success(vec![Content::text(json)])
1495 }
1496 None => tool_error("no recording is active — start one first"),
1497 },
1498 RecordingAction::Import => {
1499 let Some(session_json) = ¶ms.session_json else {
1500 return missing_param("session_json", "import");
1501 };
1502 if session_json.len() > MAX_SESSION_JSON {
1503 return tool_error("session JSON exceeds maximum size (10 MB)");
1504 }
1505 let session: victauri_core::RecordedSession =
1506 match serde_json::from_str(session_json) {
1507 Ok(s) => s,
1508 Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1509 };
1510
1511 let result = serde_json::json!({
1512 "imported": true,
1513 "session_id": session.id,
1514 "event_count": session.events.len(),
1515 "checkpoint_count": session.checkpoints.len(),
1516 "started_at": session.started_at.to_rfc3339(),
1517 });
1518 self.state.recorder.import(session);
1519 CallToolResult::success(vec![Content::text(result.to_string())])
1520 }
1521 RecordingAction::Replay => {
1522 let calls = self.state.recorder.ipc_replay_sequence();
1523 if calls.is_empty() {
1524 return tool_error("no IPC calls recorded — record a session first");
1525 }
1526 let mut replay_results = Vec::new();
1527 for call in &calls {
1528 let code = format!(
1529 "return window.__TAURI_INTERNALS__.invoke({})",
1530 js_string(&call.command)
1531 );
1532 let outcome = match self
1533 .eval_with_return(&code, params.webview_label.as_deref())
1534 .await
1535 {
1536 Ok(result_str) => {
1537 let value: serde_json::Value = serde_json::from_str(&result_str)
1538 .unwrap_or(serde_json::Value::String(result_str));
1539 let shape = crate::introspection::JsonShape::from_value(&value);
1540 serde_json::json!({
1541 "command": call.command,
1542 "status": "ok",
1543 "response_type": shape.type_name(),
1544 })
1545 }
1546 Err(e) => {
1547 serde_json::json!({
1548 "command": call.command,
1549 "status": "error",
1550 "error": e,
1551 })
1552 }
1553 };
1554 replay_results.push(outcome);
1555 }
1556 let passed = replay_results
1557 .iter()
1558 .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
1559 .count();
1560 let result = serde_json::json!({
1561 "replayed": replay_results.len(),
1562 "passed": passed,
1563 "failed": replay_results.len() - passed,
1564 "results": replay_results,
1565 });
1566 json_result(&result)
1567 }
1568 }
1569 }
1570
1571 #[tool(
1572 description = "CSS and visual inspection. Actions: get_styles (computed CSS for element), get_bounding_boxes (layout rects), highlight (debug overlay), clear_highlights, audit_accessibility (a11y audit), get_performance (timing/heap/DOM metrics).",
1573 annotations(
1574 read_only_hint = true,
1575 destructive_hint = false,
1576 idempotent_hint = true,
1577 open_world_hint = false
1578 )
1579 )]
1580 async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1581 match params.action {
1582 InspectAction::GetStyles => {
1583 let Some(ref_id) = ¶ms.ref_id else {
1584 return missing_param("ref_id", "get_styles");
1585 };
1586 let props_arg = match ¶ms.properties {
1587 Some(props) => {
1588 let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1589 format!("[{}]", arr.join(","))
1590 }
1591 None => "null".to_string(),
1592 };
1593 let code = format!(
1594 "return window.__VICTAURI__?.getStyles({}, {})",
1595 js_string(ref_id),
1596 props_arg
1597 );
1598 self.eval_bridge(&code, params.webview_label.as_deref())
1599 .await
1600 }
1601 InspectAction::GetBoundingBoxes => {
1602 let Some(ref_ids) = ¶ms.ref_ids else {
1603 return missing_param("ref_ids", "get_bounding_boxes");
1604 };
1605 let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1606 let code = format!(
1607 "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1608 refs.join(",")
1609 );
1610 self.eval_bridge(&code, params.webview_label.as_deref())
1611 .await
1612 }
1613 InspectAction::Highlight => {
1614 let Some(ref_id) = ¶ms.ref_id else {
1615 return missing_param("ref_id", "highlight");
1616 };
1617 let color_arg = match ¶ms.color {
1618 Some(c) => match sanitize_css_color(c) {
1619 Ok(safe) => format!("\"{safe}\""),
1620 Err(e) => return tool_error(e),
1621 },
1622 None => "null".to_string(),
1623 };
1624 let label_arg = match ¶ms.label {
1625 Some(l) => js_string(l),
1626 None => "null".to_string(),
1627 };
1628 let code = format!(
1629 "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1630 js_string(ref_id),
1631 color_arg,
1632 label_arg
1633 );
1634 self.eval_bridge(&code, params.webview_label.as_deref())
1635 .await
1636 }
1637 InspectAction::ClearHighlights => {
1638 self.eval_bridge(
1639 "return window.__VICTAURI__?.clearHighlights()",
1640 params.webview_label.as_deref(),
1641 )
1642 .await
1643 }
1644 InspectAction::AuditAccessibility => {
1645 self.eval_bridge(
1646 "return window.__VICTAURI__?.auditAccessibility()",
1647 params.webview_label.as_deref(),
1648 )
1649 .await
1650 }
1651 InspectAction::GetPerformance => {
1652 self.eval_bridge(
1653 "return window.__VICTAURI__?.getPerformanceMetrics()",
1654 params.webview_label.as_deref(),
1655 )
1656 .await
1657 }
1658 }
1659 }
1660
1661 #[tool(
1662 description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1663 annotations(
1664 read_only_hint = false,
1665 destructive_hint = false,
1666 idempotent_hint = true,
1667 open_world_hint = false
1668 )
1669 )]
1670 async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1671 match params.action {
1672 CssAction::Inject => {
1673 if !self.state.privacy.is_tool_enabled("inject_css") {
1674 return tool_disabled("inject_css");
1675 }
1676 let Some(css) = ¶ms.css else {
1677 return missing_param("css", "inject");
1678 };
1679 let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1680 self.eval_bridge(&code, params.webview_label.as_deref())
1681 .await
1682 }
1683 CssAction::Remove => {
1684 if !self.state.privacy.is_tool_enabled("css.remove") {
1685 return tool_disabled("css.remove");
1686 }
1687 self.eval_bridge(
1688 "return window.__VICTAURI__?.removeInjectedCss()",
1689 params.webview_label.as_deref(),
1690 )
1691 .await
1692 }
1693 }
1694 }
1695
1696 #[tool(
1697 description = "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls).",
1698 annotations(
1699 read_only_hint = true,
1700 destructive_hint = false,
1701 idempotent_hint = true,
1702 open_world_hint = false
1703 )
1704 )]
1705 async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
1706 match params.action {
1707 LogsAction::Console => {
1708 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1709 let code = if since_arg.is_empty() {
1710 "return window.__VICTAURI__?.getConsoleLogs()".to_string()
1711 } else {
1712 format!("return window.__VICTAURI__?.getConsoleLogs({since_arg})")
1713 };
1714 self.eval_bridge(&code, params.webview_label.as_deref())
1715 .await
1716 }
1717 LogsAction::Network => {
1718 let filter_arg = params
1719 .filter
1720 .as_ref()
1721 .map_or_else(|| "null".to_string(), |f| js_string(f));
1722 let limit_arg = params
1723 .limit
1724 .map_or_else(|| "null".to_string(), |l| l.to_string());
1725 let code =
1726 format!("return window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit_arg})");
1727 self.eval_bridge(&code, params.webview_label.as_deref())
1728 .await
1729 }
1730 LogsAction::Ipc => {
1731 let wait = params.wait_for_capture.unwrap_or(false);
1732 let limit_arg = params.limit.map(|l| format!("{l}")).unwrap_or_default();
1733 if wait {
1734 let limit_js = if limit_arg.is_empty() {
1735 "undefined".to_string()
1736 } else {
1737 limit_arg.clone()
1738 };
1739 let code = format!(
1740 r"return (async function() {{
1741 await window.__VICTAURI__.waitForIpcComplete(500);
1742 var log = window.__VICTAURI__.getIpcLog() || [];
1743 var lim = {limit_js};
1744 return (lim !== undefined) ? log.slice(-lim) : log;
1745 }})()"
1746 );
1747 let timeout = std::time::Duration::from_millis(5000);
1748 match self
1749 .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
1750 .await
1751 {
1752 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1753 Err(e) => tool_error(e),
1754 }
1755 } else {
1756 let code = if limit_arg.is_empty() {
1757 "return window.__VICTAURI__?.getIpcLog()".to_string()
1758 } else {
1759 format!("return window.__VICTAURI__?.getIpcLog({limit_arg})")
1760 };
1761 self.eval_bridge(&code, params.webview_label.as_deref())
1762 .await
1763 }
1764 }
1765 LogsAction::Navigation => {
1766 self.eval_bridge(
1767 "return window.__VICTAURI__?.getNavigationLog()",
1768 params.webview_label.as_deref(),
1769 )
1770 .await
1771 }
1772 LogsAction::Dialogs => {
1773 self.eval_bridge(
1774 "return window.__VICTAURI__?.getDialogLog()",
1775 params.webview_label.as_deref(),
1776 )
1777 .await
1778 }
1779 LogsAction::Events => {
1780 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1781 let code = if since_arg.is_empty() {
1782 "return window.__VICTAURI__?.getEventStream()".to_string()
1783 } else {
1784 format!("return window.__VICTAURI__?.getEventStream({since_arg})")
1785 };
1786 self.eval_bridge(&code, params.webview_label.as_deref())
1787 .await
1788 }
1789 LogsAction::SlowIpc => {
1790 let Some(threshold) = params.threshold_ms else {
1791 return missing_param("threshold_ms", "slow_ipc");
1792 };
1793 let limit = params.limit.unwrap_or(20);
1794 let code = format!(
1795 r"return (function() {{
1796 var log = window.__VICTAURI__?.getIpcLog() || [];
1797 var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
1798 slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
1799 return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}) }};
1800 }})()",
1801 );
1802 self.eval_bridge(&code, None).await
1803 }
1804 }
1805 }
1806
1807 #[tool(
1810 description = "Deep backend introspection — command profiling, IPC contract testing, \
1811 coverage, startup timing, capability auditing, database diagnostics, process \
1812 enumeration, and event bus monitoring. \
1813 These features exploit Victauri's position inside the Rust process.\n\n\
1814 Actions:\n\
1815 - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
1816 - `coverage`: Which registered commands have been called during this session.\n\
1817 - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
1818 - `contract_check`: Check all recorded contracts for schema drift.\n\
1819 - `contract_list`: List all recorded contract baselines.\n\
1820 - `contract_clear`: Clear all recorded contract baselines.\n\
1821 - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
1822 - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
1823 - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
1824 - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
1825 - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
1826 - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
1827 - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
1828 - `event_bus_clear`: Clear the event bus capture buffer.",
1829 annotations(
1830 read_only_hint = true,
1831 destructive_hint = false,
1832 idempotent_hint = true,
1833 open_world_hint = false
1834 )
1835 )]
1836 async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
1837 self.track_tool_call();
1838 if !self.state.privacy.is_tool_enabled("introspect") {
1839 return tool_disabled("introspect");
1840 }
1841
1842 match params.action {
1843 IntrospectAction::CommandTimings => {
1844 let mut stats = self.state.command_timings.all_stats();
1845 if let Some(threshold) = params.slow_threshold_ms {
1846 stats.retain(|s| s.avg_ms >= threshold);
1847 }
1848 let result = serde_json::json!({
1849 "commands": stats,
1850 "total_commands_profiled": self.state.command_timings.all_stats().len(),
1851 "slow_threshold_ms": params.slow_threshold_ms,
1852 });
1853 json_result(&result)
1854 }
1855 IntrospectAction::Coverage => {
1856 let registered: Vec<String> = self
1857 .state
1858 .registry
1859 .list()
1860 .iter()
1861 .map(|c| c.name.clone())
1862 .collect();
1863
1864 let code = "return window.__VICTAURI__?.getIpcLog()";
1865 let invoked: std::collections::HashSet<String> = match self
1866 .eval_with_return(code, params.webview_label.as_deref())
1867 .await
1868 {
1869 Ok(json_str) => {
1870 if let Ok(entries) =
1871 serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
1872 {
1873 entries
1874 .iter()
1875 .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
1876 .map(String::from)
1877 .collect()
1878 } else {
1879 std::collections::HashSet::new()
1880 }
1881 }
1882 Err(_) => std::collections::HashSet::new(),
1883 };
1884
1885 let uncovered: Vec<&String> = registered
1886 .iter()
1887 .filter(|cmd| !invoked.contains(cmd.as_str()))
1888 .collect();
1889
1890 let coverage_pct = if registered.is_empty() {
1891 100.0
1892 } else {
1893 let covered = registered.len() - uncovered.len();
1894 (covered as f64 / registered.len() as f64) * 100.0
1895 };
1896
1897 let result = serde_json::json!({
1898 "registered_commands": registered.len(),
1899 "invoked_commands": invoked.len(),
1900 "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
1901 "uncovered": uncovered,
1902 "invoked_not_registered": invoked.iter()
1903 .filter(|cmd| !registered.contains(cmd))
1904 .collect::<Vec<_>>(),
1905 });
1906 json_result(&result)
1907 }
1908 IntrospectAction::ContractRecord => {
1909 let Some(command) = params.command else {
1910 return missing_param("command", "contract_record");
1911 };
1912 let args_json = params.args.unwrap_or(serde_json::json!({}));
1913 let args_str =
1914 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
1915 let code = format!(
1916 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
1917 js_string(&command)
1918 );
1919 match self
1920 .eval_with_return(&code, params.webview_label.as_deref())
1921 .await
1922 {
1923 Ok(result_str) => {
1924 let value: serde_json::Value = serde_json::from_str(&result_str)
1925 .unwrap_or(serde_json::Value::String(result_str.clone()));
1926 let shape = crate::introspection::JsonShape::from_value(&value);
1927 let sample = if result_str.len() > 4096 {
1928 format!("{}...(truncated)", &result_str[..4096])
1929 } else {
1930 result_str
1931 };
1932 let baseline = crate::introspection::ContractBaseline {
1933 command: command.clone(),
1934 args: args_json,
1935 shape: shape.clone(),
1936 sample,
1937 recorded_at: chrono_now(),
1938 };
1939 self.state.contract_store.record(baseline);
1940 let result = serde_json::json!({
1941 "recorded": true,
1942 "command": command,
1943 "shape_type": shape.type_name(),
1944 });
1945 json_result(&result)
1946 }
1947 Err(e) => tool_error(format!(
1948 "failed to invoke '{command}' for contract recording: {e}"
1949 )),
1950 }
1951 }
1952 IntrospectAction::ContractCheck => {
1953 let baselines = self.state.contract_store.all();
1954 if baselines.is_empty() {
1955 return json_result(&serde_json::json!({
1956 "checked": 0,
1957 "message": "no contract baselines recorded — use contract_record first",
1958 }));
1959 }
1960 let mut results = Vec::new();
1961 for baseline in &baselines {
1962 let args_str =
1963 serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
1964 let code = format!(
1965 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
1966 js_string(&baseline.command)
1967 );
1968 match self
1969 .eval_with_return(&code, params.webview_label.as_deref())
1970 .await
1971 {
1972 Ok(result_str) => {
1973 let value: serde_json::Value = serde_json::from_str(&result_str)
1974 .unwrap_or(serde_json::Value::String(result_str));
1975 let current_shape = crate::introspection::JsonShape::from_value(&value);
1976 let drift = crate::introspection::diff_shapes(
1977 &baseline.shape,
1978 ¤t_shape,
1979 &baseline.command,
1980 );
1981 results.push(drift);
1982 }
1983 Err(e) => {
1984 results.push(crate::introspection::ContractDrift {
1985 command: baseline.command.clone(),
1986 new_fields: Vec::new(),
1987 removed_fields: Vec::new(),
1988 type_changes: Vec::new(),
1989 shape_matches: false,
1990 });
1991 tracing::warn!(
1992 command = %baseline.command,
1993 error = %e,
1994 "contract check invocation failed"
1995 );
1996 }
1997 }
1998 }
1999 let passing = results.iter().filter(|r| r.shape_matches).count();
2000 let result = serde_json::json!({
2001 "checked": results.len(),
2002 "passing": passing,
2003 "failing": results.len() - passing,
2004 "contracts": results,
2005 });
2006 json_result(&result)
2007 }
2008 IntrospectAction::ContractList => {
2009 let baselines = self.state.contract_store.all();
2010 let result = serde_json::json!({
2011 "count": baselines.len(),
2012 "baselines": baselines.iter().map(|b| serde_json::json!({
2013 "command": b.command,
2014 "shape_type": b.shape.type_name(),
2015 "recorded_at": b.recorded_at,
2016 })).collect::<Vec<_>>(),
2017 });
2018 json_result(&result)
2019 }
2020 IntrospectAction::ContractClear => {
2021 let cleared = self.state.contract_store.clear();
2022 json_result(&serde_json::json!({
2023 "cleared": cleared,
2024 }))
2025 }
2026 IntrospectAction::StartupTiming => {
2027 let phases = self.state.startup_timeline.report();
2028 let result = serde_json::json!({
2029 "phases": phases,
2030 "total_ms": self.state.startup_timeline.total_ms(),
2031 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2032 });
2033 json_result(&result)
2034 }
2035 IntrospectAction::Capabilities => {
2036 let config = self.bridge.tauri_config();
2037 let live_windows = self.bridge.list_window_labels();
2038
2039 let result = serde_json::json!({
2040 "app": {
2041 "identifier": config.get("identifier"),
2042 "product_name": config.get("product_name"),
2043 "version": config.get("version"),
2044 },
2045 "security": config.get("security"),
2046 "configured_windows": config.get("windows"),
2047 "live_windows": live_windows,
2048 "configured_plugins": config.get("plugins"),
2049 "victauri": {
2050 "registered_commands": self.state.registry.list().len(),
2051 "auth_enabled": self.state.privacy.redaction_enabled,
2052 "privacy_profile": format!("{:?}", self.state.privacy.profile),
2053 "disabled_tools": &self.state.privacy.disabled_tools,
2054 },
2055 });
2056 json_result(&result)
2057 }
2058 #[allow(unused_variables)]
2059 IntrospectAction::DbHealth => {
2060 #[cfg(feature = "sqlite")]
2061 {
2062 let db_path = params.db_path.clone();
2063 match self.run_db_health(db_path.as_deref()).await {
2064 Ok(health) => json_result(&health),
2065 Err(e) => tool_error(format!("db_health failed: {e}")),
2066 }
2067 }
2068 #[cfg(not(feature = "sqlite"))]
2069 {
2070 tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2071 }
2072 }
2073 IntrospectAction::PluginState => {
2074 let recording_active = self.state.recorder.is_recording();
2075 let recording_events = self.state.recorder.event_count();
2076 let result = serde_json::json!({
2077 "event_log": {
2078 "size": self.state.event_log.len(),
2079 "capacity": self.state.event_log.capacity(),
2080 },
2081 "registry": {
2082 "commands_registered": self.state.registry.list().len(),
2083 },
2084 "recording": {
2085 "active": recording_active,
2086 "events_captured": recording_events,
2087 },
2088 "faults": {
2089 "active_rules": self.state.fault_registry.list().len(),
2090 },
2091 "contracts": {
2092 "baselines_recorded": self.state.contract_store.all().len(),
2093 },
2094 "timings": {
2095 "commands_profiled": self.state.command_timings.all_stats().len(),
2096 },
2097 "event_bus": {
2098 "captured_events": self.state.event_bus.len(),
2099 },
2100 "tasks": {
2101 "total": self.state.task_tracker.list().len(),
2102 "active": self.state.task_tracker.active_count(),
2103 },
2104 "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2105 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2106 "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2107 });
2108 json_result(&result)
2109 }
2110 IntrospectAction::Processes => {
2111 let pid = std::process::id();
2112 let uptime = self.state.started_at.elapsed();
2113 let children = crate::introspection::enumerate_child_processes();
2114 let host_memory = crate::memory::current_stats();
2115
2116 let result = serde_json::json!({
2117 "host": {
2118 "pid": pid,
2119 "uptime_secs": uptime.as_secs(),
2120 "platform": std::env::consts::OS,
2121 "arch": std::env::consts::ARCH,
2122 "memory": host_memory,
2123 },
2124 "children": children.iter().map(|c| serde_json::json!({
2125 "pid": c.pid,
2126 "name": c.name,
2127 "memory_bytes": c.memory_bytes,
2128 })).collect::<Vec<_>>(),
2129 "child_count": children.len(),
2130 "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2131 });
2132 json_result(&result)
2133 }
2134 IntrospectAction::PluginTasks => {
2135 let tasks = self.state.task_tracker.list();
2136 let active = self.state.task_tracker.active_count();
2137 let result = serde_json::json!({
2138 "total": tasks.len(),
2139 "active": active,
2140 "finished": tasks.len() - active,
2141 "tasks": tasks,
2142 });
2143 json_result(&result)
2144 }
2145 IntrospectAction::EventBus => {
2146 let tauri_events = self.state.event_bus.events();
2147 let app_events = self.state.event_log.snapshot();
2148 let result = serde_json::json!({
2149 "tauri_events": {
2150 "count": tauri_events.len(),
2151 "events": tauri_events,
2152 },
2153 "app_events": {
2154 "count": app_events.len(),
2155 "capacity": self.state.event_log.capacity(),
2156 "events": app_events,
2157 },
2158 });
2159 json_result(&result)
2160 }
2161 IntrospectAction::EventBusClear => {
2162 let tauri_cleared = self.state.event_bus.clear();
2163 self.state.event_log.clear();
2164 json_result(&serde_json::json!({
2165 "tauri_events_cleared": tauri_cleared,
2166 "app_events_cleared": true,
2167 }))
2168 }
2169 }
2170 }
2171
2172 #[tool(
2175 description = "Inject faults into Tauri IPC commands at the Rust layer for chaos engineering. \
2176 Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2177 CDP cannot inject failures at the backend — it can only observe the frontend.\n\n\
2178 Actions:\n\
2179 - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2180 - `list`: List all active fault injection rules.\n\
2181 - `clear`: Remove a specific fault rule (requires `command`).\n\
2182 - `clear_all`: Remove all fault rules.",
2183 annotations(
2184 read_only_hint = false,
2185 destructive_hint = true,
2186 idempotent_hint = false,
2187 open_world_hint = false
2188 )
2189 )]
2190 async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2191 self.track_tool_call();
2192 if !self.state.privacy.is_tool_enabled("fault") {
2193 return tool_disabled("fault");
2194 }
2195
2196 match params.action {
2197 FaultAction::Inject => {
2198 let Some(command) = params.command else {
2199 return missing_param("command", "inject");
2200 };
2201 let Some(fault_kind) = params.fault_type else {
2202 return missing_param("fault_type", "inject");
2203 };
2204 let fault_type = match fault_kind {
2205 FaultKind::Delay => {
2206 let delay_ms = params.delay_ms.unwrap_or(1000);
2207 crate::introspection::FaultType::Delay { delay_ms }
2208 }
2209 FaultKind::Error => {
2210 let message = params
2211 .error_message
2212 .unwrap_or_else(|| "injected fault".to_string());
2213 crate::introspection::FaultType::Error { message }
2214 }
2215 FaultKind::Drop => crate::introspection::FaultType::Drop,
2216 FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2217 };
2218 let config = crate::introspection::FaultConfig {
2219 command: command.clone(),
2220 fault_type: fault_type.clone(),
2221 trigger_count: 0,
2222 max_triggers: params.max_triggers.unwrap_or(0),
2223 created_at: std::time::Instant::now(),
2224 };
2225 self.state.fault_registry.inject(config);
2226 let result = serde_json::json!({
2227 "injected": true,
2228 "command": command,
2229 "fault_type": fault_type,
2230 "max_triggers": params.max_triggers.unwrap_or(0),
2231 });
2232 json_result(&result)
2233 }
2234 FaultAction::List => {
2235 let faults = self.state.fault_registry.list();
2236 let result = serde_json::json!({
2237 "count": faults.len(),
2238 "faults": faults.iter().map(|f| serde_json::json!({
2239 "command": f.command,
2240 "fault_type": f.fault_type,
2241 "trigger_count": f.trigger_count,
2242 "max_triggers": f.max_triggers,
2243 })).collect::<Vec<_>>(),
2244 });
2245 json_result(&result)
2246 }
2247 FaultAction::Clear => {
2248 let Some(command) = params.command else {
2249 return missing_param("command", "clear");
2250 };
2251 let removed = self.state.fault_registry.clear(&command);
2252 json_result(&serde_json::json!({
2253 "removed": removed,
2254 "command": command,
2255 }))
2256 }
2257 FaultAction::ClearAll => {
2258 let removed = self.state.fault_registry.clear_all();
2259 json_result(&serde_json::json!({
2260 "removed": removed,
2261 }))
2262 }
2263 }
2264 }
2265
2266 #[tool(
2269 description = "Correlate recent activity across all layers into a coherent narrative. \
2270 CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
2271 + window events across the Rust backend and webview simultaneously.\n\n\
2272 Actions:\n\
2273 - `summary`: High-level activity summary for the last N seconds (default 30). \
2274 Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
2275 - `last_action`: Correlate the most recent burst of events into a causal timeline \
2276 (e.g. 'IPC call → DOM update → console.log').\n\
2277 - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
2278 annotations(
2279 read_only_hint = true,
2280 destructive_hint = false,
2281 idempotent_hint = true,
2282 open_world_hint = false
2283 )
2284 )]
2285 async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
2286 self.track_tool_call();
2287 if !self.state.privacy.is_tool_enabled("explain") {
2288 return tool_disabled("explain");
2289 }
2290
2291 match params.action {
2292 ExplainAction::Summary => {
2293 let secs = params.seconds.unwrap_or(30);
2294 let since = chrono::Utc::now()
2295 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2296 let events = self.state.event_log.since(since);
2297
2298 let mut ipc_count = 0u64;
2299 let mut dom_mutations = 0u64;
2300 let mut state_changes = 0u64;
2301 let mut window_events = 0u64;
2302 let mut interactions = 0u64;
2303 let mut top_commands: HashMap<String, u64> = HashMap::new();
2304 let mut errors: Vec<String> = Vec::new();
2305
2306 for event in &events {
2307 match event {
2308 victauri_core::AppEvent::Ipc(call) => {
2309 ipc_count += 1;
2310 *top_commands.entry(call.command.clone()).or_insert(0) += 1;
2311 if let victauri_core::IpcResult::Err(e) = &call.result {
2312 errors.push(format!("IPC {}: {e}", call.command));
2313 }
2314 }
2315 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2316 dom_mutations += u64::from(*mutation_count)
2317 }
2318 victauri_core::AppEvent::StateChange { caused_by, .. } => {
2319 let is_victauri =
2320 caused_by.as_ref().is_some_and(|c| c.contains("victauri"));
2321 if !is_victauri {
2322 state_changes += 1;
2323 }
2324 }
2325 victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
2326 victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
2327 _ => {}
2328 }
2329 }
2330
2331 let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
2332 sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
2333 let top: Vec<_> = sorted_cmds.iter().take(5).collect();
2334
2335 let narrative = format!(
2336 "{ipc_count} IPC call{} in the last {secs}s{}. \
2337 {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
2338 {window_events} window event{}. {}.",
2339 if ipc_count == 1 { "" } else { "s" },
2340 if top.is_empty() {
2341 String::new()
2342 } else {
2343 format!(
2344 ", dominated by {}",
2345 top.iter()
2346 .map(|(cmd, n)| format!("{cmd} ({n}x)"))
2347 .collect::<Vec<_>>()
2348 .join(", ")
2349 )
2350 },
2351 if dom_mutations == 1 { "" } else { "s" },
2352 if interactions == 1 { "" } else { "s" },
2353 if window_events == 1 { "" } else { "s" },
2354 if errors.is_empty() {
2355 "No errors".to_string()
2356 } else {
2357 format!(
2358 "{} error{}",
2359 errors.len(),
2360 if errors.len() == 1 { "" } else { "s" }
2361 )
2362 },
2363 );
2364
2365 let result = serde_json::json!({
2366 "time_window_secs": secs,
2367 "total_events": events.len(),
2368 "ipc_calls": ipc_count,
2369 "dom_mutations": dom_mutations,
2370 "state_changes": state_changes,
2371 "window_events": window_events,
2372 "interactions": interactions,
2373 "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
2374 serde_json::json!({"command": cmd, "count": n})
2375 }).collect::<Vec<_>>(),
2376 "errors": errors,
2377 "narrative": narrative,
2378 });
2379 json_result(&result)
2380 }
2381 ExplainAction::LastAction => {
2382 let secs = params.seconds.unwrap_or(5);
2383 let since = chrono::Utc::now()
2384 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2385 let events = self.state.event_log.since(since);
2386
2387 let timeline: Vec<serde_json::Value> = events
2388 .iter()
2389 .filter_map(|event| match event {
2390 victauri_core::AppEvent::Ipc(call) => {
2391 if call.command.starts_with("plugin:victauri|") {
2392 return None;
2393 }
2394 Some(serde_json::json!({
2395 "time": call.timestamp.to_rfc3339_opts(
2396 chrono::SecondsFormat::Millis, true
2397 ),
2398 "type": "ipc",
2399 "detail": format!(
2400 "{} {} ({}ms)",
2401 call.command,
2402 call.result,
2403 call.duration_ms.unwrap_or(0)
2404 ),
2405 }))
2406 }
2407 victauri_core::AppEvent::DomMutation {
2408 timestamp,
2409 mutation_count,
2410 webview_label,
2411 } => {
2412 Some(serde_json::json!({
2413 "time": timestamp.to_rfc3339_opts(
2414 chrono::SecondsFormat::Millis, true
2415 ),
2416 "type": "dom_mutation",
2417 "detail": format!(
2418 "{mutation_count} element{} updated in {webview_label}",
2419 if *mutation_count == 1 { "" } else { "s" }
2420 ),
2421 }))
2422 }
2423 victauri_core::AppEvent::DomInteraction {
2424 timestamp,
2425 action,
2426 selector,
2427 ..
2428 } => {
2429 Some(serde_json::json!({
2430 "time": timestamp.to_rfc3339_opts(
2431 chrono::SecondsFormat::Millis, true
2432 ),
2433 "type": "interaction",
2434 "detail": format!("{action} on {selector}"),
2435 }))
2436 }
2437 victauri_core::AppEvent::StateChange {
2438 timestamp,
2439 key,
2440 caused_by,
2441 } => {
2442 if caused_by.as_ref().is_some_and(|c| c.contains("victauri")) {
2443 return None;
2444 }
2445 Some(serde_json::json!({
2446 "time": timestamp.to_rfc3339_opts(
2447 chrono::SecondsFormat::Millis, true
2448 ),
2449 "type": "state_change",
2450 "detail": format!(
2451 "{key} changed{}",
2452 caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
2453 ),
2454 }))
2455 }
2456 victauri_core::AppEvent::WindowEvent {
2457 timestamp,
2458 label,
2459 event,
2460 } => {
2461 Some(serde_json::json!({
2462 "time": timestamp.to_rfc3339_opts(
2463 chrono::SecondsFormat::Millis, true
2464 ),
2465 "type": "window_event",
2466 "detail": format!("{event} on window '{label}'"),
2467 }))
2468 }
2469 _ => {
2470 Some(serde_json::json!({
2471 "time": event.timestamp().to_rfc3339_opts(
2472 chrono::SecondsFormat::Millis, true
2473 ),
2474 "type": "other",
2475 "detail": "unknown event type",
2476 }))
2477 }
2478 })
2479 .collect();
2480
2481 let narrative = if timeline.is_empty() {
2482 format!("No activity in the last {secs}s.")
2483 } else {
2484 let parts: Vec<String> = timeline
2485 .iter()
2486 .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
2487 .map(String::from)
2488 .collect();
2489 parts.join(" → ")
2490 };
2491
2492 let result = serde_json::json!({
2493 "time_window_secs": secs,
2494 "event_count": timeline.len(),
2495 "timeline": timeline,
2496 "narrative": narrative,
2497 });
2498 json_result(&result)
2499 }
2500 ExplainAction::Diff => {
2501 let secs = params.seconds.unwrap_or(10);
2502 let since = chrono::Utc::now()
2503 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2504 let events = self.state.event_log.since(since);
2505
2506 let mut ipc_commands: Vec<String> = Vec::new();
2507 let mut dom_changes = 0u64;
2508 let mut error_count = 0u64;
2509 let mut interaction_count = 0u64;
2510
2511 for event in &events {
2512 match event {
2513 victauri_core::AppEvent::Ipc(call)
2514 if !call.command.starts_with("plugin:victauri|") =>
2515 {
2516 ipc_commands.push(call.command.clone());
2517 if matches!(call.result, victauri_core::IpcResult::Err(_)) {
2518 error_count += 1;
2519 }
2520 }
2521 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2522 dom_changes += u64::from(*mutation_count)
2523 }
2524 victauri_core::AppEvent::DomInteraction { .. } => {
2525 interaction_count += 1;
2526 }
2527 _ => {}
2528 }
2529 }
2530
2531 ipc_commands.dedup();
2532
2533 let result = serde_json::json!({
2534 "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2535 "time_window_secs": secs,
2536 "total_events": events.len(),
2537 "ipc_calls_made": ipc_commands.len(),
2538 "unique_commands": ipc_commands,
2539 "dom_elements_changed": dom_changes,
2540 "interactions": interaction_count,
2541 "errors": error_count,
2542 });
2543 json_result(&result)
2544 }
2545 }
2546 }
2547}
2548
2549impl VictauriMcpHandler {
2550 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
2552 Self {
2553 state,
2554 bridge,
2555 subscriptions: Arc::new(Mutex::new(HashSet::new())),
2556 bridge_checked: Arc::new(AtomicBool::new(false)),
2557 }
2558 }
2559
2560 pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
2561 self.state.privacy.is_tool_enabled(name)
2562 }
2563
2564 pub(crate) async fn execute_tool(
2565 &self,
2566 name: &str,
2567 args: serde_json::Value,
2568 ) -> Result<CallToolResult, rest::ToolCallError> {
2569 if !self.state.privacy.is_tool_enabled(name) {
2570 return Ok(tool_disabled(name));
2571 }
2572 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2573 let start = std::time::Instant::now();
2574 tracing::debug!(tool = %name, "REST tool invocation started");
2575
2576 let result = match name {
2577 "eval_js" => {
2578 let p: EvalJsParams = Self::parse_args(args)?;
2579 self.eval_js(Parameters(p)).await
2580 }
2581 "dom_snapshot" => {
2582 let p: SnapshotParams = Self::parse_args(args)?;
2583 self.dom_snapshot(Parameters(p)).await
2584 }
2585 "find_elements" => {
2586 let p: FindElementsParams = Self::parse_args(args)?;
2587 self.find_elements(Parameters(p)).await
2588 }
2589 "invoke_command" => {
2590 let p: InvokeCommandParams = Self::parse_args(args)?;
2591 self.invoke_command(Parameters(p)).await
2592 }
2593 "screenshot" => {
2594 let p: ScreenshotParams = Self::parse_args(args)?;
2595 self.screenshot(Parameters(p)).await
2596 }
2597 "verify_state" => {
2598 let p: VerifyStateParams = Self::parse_args(args)?;
2599 self.verify_state(Parameters(p)).await
2600 }
2601 "detect_ghost_commands" => {
2602 let p: GhostCommandParams = Self::parse_args(args)?;
2603 self.detect_ghost_commands(Parameters(p)).await
2604 }
2605 "check_ipc_integrity" => {
2606 let p: IpcIntegrityParams = Self::parse_args(args)?;
2607 self.check_ipc_integrity(Parameters(p)).await
2608 }
2609 "wait_for" => {
2610 let p: WaitForParams = Self::parse_args(args)?;
2611 self.wait_for(Parameters(p)).await
2612 }
2613 "assert_semantic" => {
2614 let p: SemanticAssertParams = Self::parse_args(args)?;
2615 self.assert_semantic(Parameters(p)).await
2616 }
2617 "resolve_command" => {
2618 let p: ResolveCommandParams = Self::parse_args(args)?;
2619 self.resolve_command(Parameters(p)).await
2620 }
2621 "get_registry" => {
2622 let p: RegistryParams = Self::parse_args(args)?;
2623 self.get_registry(Parameters(p)).await
2624 }
2625 "get_memory_stats" => self.get_memory_stats().await,
2626 "get_plugin_info" => self.get_plugin_info().await,
2627 "get_diagnostics" => {
2628 let p: DiagnosticsParams = Self::parse_args(args)?;
2629 self.get_diagnostics(Parameters(p)).await
2630 }
2631 "app_info" => self.app_info().await,
2632 "list_app_dir" => {
2633 let p: ListAppDirParams = Self::parse_args(args)?;
2634 self.list_app_dir(Parameters(p)).await
2635 }
2636 "read_app_file" => {
2637 let p: ReadAppFileParams = Self::parse_args(args)?;
2638 self.read_app_file(Parameters(p)).await
2639 }
2640 #[cfg(feature = "sqlite")]
2641 "query_db" => {
2642 let p: QueryDbParams = Self::parse_args(args)?;
2643 self.query_db(Parameters(p)).await
2644 }
2645 "interact" => {
2646 let p: InteractParams = Self::parse_args(args)?;
2647 self.interact(Parameters(p)).await
2648 }
2649 "input" => {
2650 let p: InputParams = Self::parse_args(args)?;
2651 self.input(Parameters(p)).await
2652 }
2653 "window" => {
2654 let p: WindowParams = Self::parse_args(args)?;
2655 self.window(Parameters(p)).await
2656 }
2657 "storage" => {
2658 let p: StorageParams = Self::parse_args(args)?;
2659 self.storage(Parameters(p)).await
2660 }
2661 "navigate" => {
2662 let p: NavigateParams = Self::parse_args(args)?;
2663 self.navigate(Parameters(p)).await
2664 }
2665 "recording" => {
2666 let p: RecordingParams = Self::parse_args(args)?;
2667 self.recording(Parameters(p)).await
2668 }
2669 "inspect" => {
2670 let p: InspectParams = Self::parse_args(args)?;
2671 self.inspect(Parameters(p)).await
2672 }
2673 "css" => {
2674 let p: CssParams = Self::parse_args(args)?;
2675 self.css(Parameters(p)).await
2676 }
2677 "logs" => {
2678 let p: LogsParams = Self::parse_args(args)?;
2679 self.logs(Parameters(p)).await
2680 }
2681 "introspect" => {
2682 let p: IntrospectParams = Self::parse_args(args)?;
2683 self.introspect(Parameters(p)).await
2684 }
2685 "fault" => {
2686 let p: FaultParams = Self::parse_args(args)?;
2687 self.fault(Parameters(p)).await
2688 }
2689 "explain" => {
2690 let p: ExplainParams = Self::parse_args(args)?;
2691 self.explain(Parameters(p)).await
2692 }
2693 _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
2694 };
2695
2696 let elapsed = start.elapsed();
2697 tracing::debug!(
2698 tool = %name,
2699 elapsed_ms = elapsed.as_millis() as u64,
2700 "REST tool invocation completed"
2701 );
2702
2703 if self.state.privacy.redaction_enabled {
2704 Ok(Self::redact_result(result, &self.state.privacy))
2705 } else {
2706 Ok(result)
2707 }
2708 }
2709
2710 fn parse_args<T: serde::de::DeserializeOwned>(
2711 args: serde_json::Value,
2712 ) -> Result<T, rest::ToolCallError> {
2713 serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
2714 }
2715
2716 fn redact_result(
2717 mut result: CallToolResult,
2718 privacy: &crate::privacy::PrivacyConfig,
2719 ) -> CallToolResult {
2720 for item in &mut result.content {
2721 if let RawContent::Text(ref mut tc) = item.raw {
2722 tc.text = privacy.redact_output(&tc.text);
2723 }
2724 }
2725 result
2726 }
2727
2728 fn track_tool_call(&self) {
2729 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2730 }
2731
2732 fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
2733 match dir.unwrap_or(AppDir::Data) {
2734 AppDir::Data => self.bridge.app_data_dir(),
2735 AppDir::Config => self.bridge.app_config_dir(),
2736 AppDir::Log => self.bridge.app_log_dir(),
2737 AppDir::LocalData => self.bridge.app_local_data_dir(),
2738 }
2739 }
2740
2741 fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
2742 let canon_base = std::fs::canonicalize(base)
2743 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
2744 let canon_target = std::fs::canonicalize(target)
2745 .map_err(|e| format!("cannot resolve target path: {e}"))?;
2746 if !canon_target.starts_with(&canon_base) {
2747 return Err("path traversal not allowed".to_string());
2748 }
2749 Ok(())
2750 }
2751
2752 fn list_dir_recursive(
2753 dir: &std::path::Path,
2754 base: &std::path::Path,
2755 depth: u32,
2756 max_depth: u32,
2757 pattern: Option<&str>,
2758 entries: &mut Vec<serde_json::Value>,
2759 ) {
2760 let Ok(read_dir) = std::fs::read_dir(dir) else {
2761 return;
2762 };
2763 for entry in read_dir.flatten() {
2764 let path = entry.path();
2765 if path.is_symlink() {
2766 continue;
2767 }
2768 let name = entry.file_name().to_string_lossy().into_owned();
2769 let relative = path
2770 .strip_prefix(base)
2771 .unwrap_or(&path)
2772 .to_string_lossy()
2773 .into_owned();
2774
2775 if let Some(pat) = pattern
2776 && !Self::matches_glob(&name, pat)
2777 && !path.is_dir()
2778 {
2779 continue;
2780 }
2781
2782 let is_dir = path.is_dir();
2783 let meta = std::fs::metadata(&path).ok();
2784
2785 entries.push(serde_json::json!({
2786 "name": name,
2787 "path": relative,
2788 "is_dir": is_dir,
2789 "size": meta.as_ref().map(std::fs::Metadata::len),
2790 "modified": meta.as_ref()
2791 .and_then(|m| m.modified().ok())
2792 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
2793 .unwrap_or_default().as_secs()),
2794 }));
2795
2796 if is_dir && depth < max_depth {
2797 Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
2798 }
2799 }
2800 }
2801
2802 fn matches_glob(name: &str, pattern: &str) -> bool {
2803 if pattern == "*" {
2804 return true;
2805 }
2806 if let Some(suffix) = pattern.strip_prefix("*.") {
2807 return name.ends_with(&format!(".{suffix}"));
2808 }
2809 if let Some(prefix) = pattern.strip_suffix("*") {
2810 return name.starts_with(prefix);
2811 }
2812 name == pattern
2813 }
2814
2815 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
2816 match self.eval_with_return(code, webview_label).await {
2817 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2818 Err(e) => tool_error(e),
2819 }
2820 }
2821
2822 async fn eval_with_return(
2823 &self,
2824 code: &str,
2825 webview_label: Option<&str>,
2826 ) -> Result<String, String> {
2827 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
2828 .await
2829 }
2830
2831 async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
2832 let id = uuid::Uuid::new_v4().to_string();
2833 let (tx, rx) = tokio::sync::oneshot::channel();
2834 {
2835 let mut pending = self.state.pending_evals.lock().await;
2836 pending.insert(id.clone(), tx);
2837 }
2838 let id_js = js_string(&id);
2839 let probe = format!(
2840 r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
2841 );
2842 if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
2843 self.state.pending_evals.lock().await.remove(&id);
2844 return Err(format!("eval injection failed: {e}"));
2845 }
2846 if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
2847 Ok(())
2848 } else {
2849 self.state.pending_evals.lock().await.remove(&id);
2850 let label = webview_label.unwrap_or("default");
2851 Err(format!(
2852 "bridge not responding on window '{label}' — the window may be hidden, \
2853 missing the victauri capability, or the JS bridge is not loaded"
2854 ))
2855 }
2856 }
2857
2858 async fn eval_with_return_timeout(
2859 &self,
2860 code: &str,
2861 webview_label: Option<&str>,
2862 timeout: std::time::Duration,
2863 ) -> Result<String, String> {
2864 self.track_tool_call();
2865
2866 if webview_label.is_some() {
2867 self.probe_bridge(webview_label).await?;
2868 }
2869
2870 let id = uuid::Uuid::new_v4().to_string();
2871 let (tx, rx) = tokio::sync::oneshot::channel();
2872
2873 {
2874 let mut pending = self.state.pending_evals.lock().await;
2875 if pending.len() >= MAX_PENDING_EVALS {
2876 return Err(format!(
2877 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
2878 ));
2879 }
2880 pending.insert(id.clone(), tx);
2881 }
2882
2883 let code = code.trim();
2887 let needs_return = !code.starts_with("return ")
2888 && !code.starts_with("return;")
2889 && !code.starts_with('{')
2890 && !code.starts_with("if ")
2891 && !code.starts_with("if(")
2892 && !code.starts_with("for ")
2893 && !code.starts_with("for(")
2894 && !code.starts_with("while ")
2895 && !code.starts_with("while(")
2896 && !code.starts_with("switch ")
2897 && !code.starts_with("try ")
2898 && !code.starts_with("const ")
2899 && !code.starts_with("let ")
2900 && !code.starts_with("var ")
2901 && !code.starts_with("function ")
2902 && !code.starts_with("class ")
2903 && !code.starts_with("throw ");
2904 let code = if needs_return {
2905 format!("return {code}")
2906 } else {
2907 code.to_string()
2908 };
2909
2910 let id_js = js_string(&id);
2911 let inject = format!(
2912 r#"
2913 (async () => {{
2914 try {{
2915 const __result = await (async () => {{ {code} }})();
2916 const __serialized = __result === undefined ? '"undefined"'
2917 : __result === null ? 'null'
2918 : JSON.stringify(__result) ?? 'null';
2919 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
2920 id: {id_js},
2921 result: __serialized
2922 }});
2923 }} catch (e) {{
2924 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
2925 id: {id_js},
2926 result: JSON.stringify({{ __error: String(e && e.message || e) }})
2927 }});
2928 }}
2929 }})();
2930 "#
2931 );
2932
2933 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
2934 self.state.pending_evals.lock().await.remove(&id);
2935 return Err(format!("eval injection failed: {e}"));
2936 }
2937
2938 match tokio::time::timeout(timeout, rx).await {
2939 Ok(Ok(result)) => {
2940 self.check_bridge_version_once();
2941 Ok(result)
2942 }
2943 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
2944 Err(_) => {
2945 self.state.pending_evals.lock().await.remove(&id);
2946 Err(format!("eval timed out after {}s", timeout.as_secs()))
2947 }
2948 }
2949 }
2950
2951 #[cfg(feature = "sqlite")]
2952 async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
2953 let data_dir = self.bridge.app_data_dir()?;
2954 let path = if let Some(p) = db_path {
2955 data_dir.join(p)
2956 } else {
2957 let mut found = None;
2958 if let Ok(entries) = std::fs::read_dir(&data_dir) {
2959 for entry in entries.flatten() {
2960 let p = entry.path();
2961 if p.extension()
2962 .is_some_and(|ext| ext == "db" || ext == "sqlite" || ext == "sqlite3")
2963 {
2964 found = Some(p);
2965 break;
2966 }
2967 }
2968 }
2969 found.ok_or_else(|| "no database found in app data directory".to_string())?
2970 };
2971 Self::safe_within(&data_dir, &path)?;
2972
2973 let path_str = path
2974 .to_str()
2975 .ok_or_else(|| "invalid path encoding".to_string())?
2976 .to_string();
2977
2978 tokio::task::spawn_blocking(move || {
2979 let conn = rusqlite::Connection::open_with_flags(
2980 &path_str,
2981 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
2982 )
2983 .map_err(|e| format!("cannot open database: {e}"))?;
2984
2985 let journal_mode: String = conn
2986 .pragma_query_value(None, "journal_mode", |r| r.get(0))
2987 .unwrap_or_else(|_| "unknown".to_string());
2988
2989 let page_count: i64 = conn
2990 .pragma_query_value(None, "page_count", |r| r.get(0))
2991 .unwrap_or(0);
2992
2993 let page_size: i64 = conn
2994 .pragma_query_value(None, "page_size", |r| r.get(0))
2995 .unwrap_or(0);
2996
2997 let freelist_count: i64 = conn
2998 .pragma_query_value(None, "freelist_count", |r| r.get(0))
2999 .unwrap_or(0);
3000
3001 let wal_checkpoint: String = if journal_mode == "wal" {
3002 let mut info = String::from("n/a");
3003 let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
3004 let busy: i64 = r.get(0)?;
3005 let checkpointed: i64 = r.get(1)?;
3006 let total: i64 = r.get(2)?;
3007 info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
3008 Ok(())
3009 });
3010 info
3011 } else {
3012 "n/a (not WAL mode)".to_string()
3013 };
3014
3015 let integrity: String = conn
3016 .pragma_query_value(None, "quick_check", |r| r.get(0))
3017 .unwrap_or_else(|_| "failed".to_string());
3018
3019 let db_size_bytes = page_count * page_size;
3020 let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
3021
3022 let mut tables = Vec::new();
3023 if let Ok(mut stmt) =
3024 conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
3025 && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
3026 {
3027 for name in rows.flatten() {
3028 let count: i64 = conn
3029 .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
3030 .unwrap_or(0);
3031 tables.push(serde_json::json!({
3032 "name": name,
3033 "row_count": count,
3034 }));
3035 }
3036 }
3037
3038 Ok(serde_json::json!({
3039 "database": path_str,
3040 "journal_mode": journal_mode,
3041 "page_count": page_count,
3042 "page_size": page_size,
3043 "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
3044 "freelist_count": freelist_count,
3045 "wal_checkpoint": wal_checkpoint,
3046 "integrity_check": integrity,
3047 "tables": tables,
3048 }))
3049 })
3050 .await
3051 .map_err(|e| format!("db health task failed: {e}"))?
3052 }
3053
3054 fn check_bridge_version_once(&self) {
3055 if self.bridge_checked.swap(true, Ordering::Relaxed) {
3056 return;
3057 }
3058 let handler = self.clone();
3059 tokio::spawn(async move {
3060 match handler
3061 .eval_with_return_timeout(
3062 "window.__VICTAURI__?.version",
3063 None,
3064 std::time::Duration::from_secs(5),
3065 )
3066 .await
3067 {
3068 Ok(v) => {
3069 let v = v.trim_matches('"');
3070 if v == BRIDGE_VERSION {
3071 tracing::debug!("Bridge version verified: {v}");
3072 } else {
3073 tracing::warn!(
3074 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
3075 );
3076 }
3077 }
3078 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
3079 }
3080 });
3081 }
3082}
3083
3084const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
3085It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
3086(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
3087(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
3088\n\nBACKEND tools (direct Rust access, no webview needed): \
3089'app_info' (app config, directory paths, discovered databases, process info), \
3090'list_app_dir' (browse app data/config/log directories), \
3091'read_app_file' (read files from app directories), \
3092'query_db' (read-only SQLite queries with auto-discovery). \
3093\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
3094'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
3095capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
3096Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
3097capability/security auditing, database diagnostics, plugin state, child process enumeration, \
3098task tracking, and automatic Tauri event bus monitoring. \
3099'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
3100drops, and response corruption into Tauri commands at the Rust layer. \
3101'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
3102activity across IPC + DOM + console + network + window events into a coherent narrative. \
3103\n\nWEBVIEW tools: \
3104'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
3105'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
3106'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
3107\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
3108\n\nCOMPOUND tools with an 'action' parameter: \
3109'window' (get_state, list, manage, resize, move_to, set_title), \
3110'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
3111set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
3112get_events, events_between, get_replay, export, import, replay), \
3113'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
3114\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
3115get_memory_stats, get_plugin_info, get_diagnostics.";
3116
3117impl ServerHandler for VictauriMcpHandler {
3118 fn get_info(&self) -> ServerInfo {
3119 ServerInfo::new(
3120 ServerCapabilities::builder()
3121 .enable_tools()
3122 .enable_resources()
3123 .enable_resources_subscribe()
3124 .build(),
3125 )
3126 .with_instructions(SERVER_INSTRUCTIONS)
3127 }
3128
3129 async fn list_tools(
3130 &self,
3131 _request: Option<PaginatedRequestParams>,
3132 _context: RequestContext<RoleServer>,
3133 ) -> Result<ListToolsResult, ErrorData> {
3134 let all_tools = Self::tool_router().list_all();
3135 let filtered: Vec<Tool> = all_tools
3136 .into_iter()
3137 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
3138 .collect();
3139 Ok(ListToolsResult {
3140 tools: filtered,
3141 ..Default::default()
3142 })
3143 }
3144
3145 async fn call_tool(
3146 &self,
3147 request: CallToolRequestParams,
3148 context: RequestContext<RoleServer>,
3149 ) -> Result<CallToolResult, ErrorData> {
3150 let tool_name: String = request.name.as_ref().to_owned();
3151 if !self.state.privacy.is_tool_enabled(&tool_name) {
3152 tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
3153 return Ok(tool_disabled(&tool_name));
3154 }
3155 self.state
3156 .tool_invocations
3157 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3158 let start = std::time::Instant::now();
3159 tracing::debug!(tool = %tool_name, "tool invocation started");
3160 let ctx = ToolCallContext::new(self, request, context);
3161 let result = Self::tool_router().call(ctx).await;
3162 let elapsed = start.elapsed();
3163 tracing::debug!(
3164 tool = %tool_name,
3165 elapsed_ms = elapsed.as_millis() as u64,
3166 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
3167 "tool invocation completed"
3168 );
3169
3170 if self.state.privacy.redaction_enabled {
3173 result.map(|mut r| {
3174 for item in &mut r.content {
3175 if let RawContent::Text(ref mut tc) = item.raw {
3176 tc.text = self.state.privacy.redact_output(&tc.text);
3177 }
3178 }
3179 r
3180 })
3181 } else {
3182 result
3183 }
3184 }
3185
3186 fn get_tool(&self, name: &str) -> Option<Tool> {
3187 if !self.state.privacy.is_tool_enabled(name) {
3188 return None;
3189 }
3190 Self::tool_router().get(name).cloned()
3191 }
3192
3193 async fn list_resources(
3194 &self,
3195 _request: Option<PaginatedRequestParams>,
3196 _context: RequestContext<RoleServer>,
3197 ) -> Result<ListResourcesResult, ErrorData> {
3198 Ok(ListResourcesResult {
3199 resources: vec![
3200 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
3201 .with_description(
3202 "Live IPC call log — all commands invoked between frontend and backend",
3203 )
3204 .with_mime_type("application/json")
3205 .no_annotation(),
3206 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
3207 .with_description(
3208 "Current state of all Tauri windows — position, size, visibility, focus",
3209 )
3210 .with_mime_type("application/json")
3211 .no_annotation(),
3212 RawResource::new(RESOURCE_URI_STATE, "state")
3213 .with_description(
3214 "Victauri plugin state — event count, registered commands, memory stats",
3215 )
3216 .with_mime_type("application/json")
3217 .no_annotation(),
3218 ],
3219 ..Default::default()
3220 })
3221 }
3222
3223 async fn read_resource(
3224 &self,
3225 request: ReadResourceRequestParams,
3226 _context: RequestContext<RoleServer>,
3227 ) -> Result<ReadResourceResult, ErrorData> {
3228 let uri = &request.uri;
3229 let json = match uri.as_str() {
3230 RESOURCE_URI_IPC_LOG => {
3231 if let Ok(json) = self
3232 .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
3233 .await
3234 {
3235 json
3236 } else {
3237 let calls = self.state.event_log.ipc_calls();
3238 serde_json::to_string_pretty(&calls)
3239 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3240 }
3241 }
3242 RESOURCE_URI_WINDOWS => {
3243 let states = self.bridge.get_window_states(None);
3244 serde_json::to_string_pretty(&states)
3245 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3246 }
3247 RESOURCE_URI_STATE => {
3248 let state_json = serde_json::json!({
3249 "events_captured": self.state.event_log.len(),
3250 "commands_registered": self.state.registry.count(),
3251 "memory": crate::memory::current_stats(),
3252 "port": self.state.port.load(Ordering::Relaxed),
3253 });
3254 serde_json::to_string_pretty(&state_json)
3255 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3256 }
3257 _ => {
3258 return Err(ErrorData::resource_not_found(
3259 format!("unknown resource: {uri}"),
3260 None,
3261 ));
3262 }
3263 };
3264
3265 let json = if self.state.privacy.redaction_enabled {
3266 self.state.privacy.redact_output(&json)
3267 } else {
3268 json
3269 };
3270
3271 Ok(ReadResourceResult::new(vec![ResourceContents::text(
3272 json, uri,
3273 )]))
3274 }
3275
3276 async fn subscribe(
3277 &self,
3278 request: SubscribeRequestParams,
3279 _context: RequestContext<RoleServer>,
3280 ) -> Result<(), ErrorData> {
3281 let uri = &request.uri;
3282 match uri.as_str() {
3283 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
3284 self.subscriptions.lock().await.insert(uri.clone());
3285 tracing::info!("Client subscribed to resource: {uri}");
3286 Ok(())
3287 }
3288 _ => Err(ErrorData::resource_not_found(
3289 format!("unknown resource: {uri}"),
3290 None,
3291 )),
3292 }
3293 }
3294
3295 async fn unsubscribe(
3296 &self,
3297 request: UnsubscribeRequestParams,
3298 _context: RequestContext<RoleServer>,
3299 ) -> Result<(), ErrorData> {
3300 self.subscriptions.lock().await.remove(&request.uri);
3301 tracing::info!("Client unsubscribed from resource: {}", request.uri);
3302 Ok(())
3303 }
3304}
3305
3306#[cfg(test)]
3307mod tests {
3308 use super::*;
3309
3310 #[test]
3311 fn js_string_simple() {
3312 assert_eq!(js_string("hello"), "\"hello\"");
3313 }
3314
3315 #[test]
3316 fn js_string_single_quotes() {
3317 let result = js_string("it's a test");
3318 assert!(result.contains("it's a test"));
3319 }
3320
3321 #[test]
3322 fn js_string_double_quotes() {
3323 let result = js_string(r#"say "hello""#);
3324 assert!(result.contains(r#"\""#));
3325 }
3326
3327 #[test]
3328 fn js_string_backslashes() {
3329 let result = js_string(r"path\to\file");
3330 assert!(result.contains(r"\\"));
3331 }
3332
3333 #[test]
3334 fn js_string_newlines_and_tabs() {
3335 let result = js_string("line1\nline2\ttab");
3336 assert!(result.contains(r"\n"));
3337 assert!(result.contains(r"\t"));
3338 assert!(!result.contains('\n'));
3339 }
3340
3341 #[test]
3342 fn js_string_null_bytes() {
3343 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
3344 let result = js_string(&input);
3345 assert!(result.contains("\\u0000"));
3347 assert!(!result.contains('\0'));
3348 }
3349
3350 #[test]
3351 fn js_string_template_literal_injection() {
3352 let result = js_string("`${alert(1)}`");
3353 assert!(result.starts_with('"'));
3356 assert!(result.ends_with('"'));
3357 }
3358
3359 #[test]
3360 fn js_string_unicode_separators() {
3361 let result = js_string("a\u{2028}b\u{2029}c");
3366 let decoded: String = serde_json::from_str(&result).unwrap();
3368 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
3369 }
3370
3371 #[test]
3372 fn js_string_empty() {
3373 assert_eq!(js_string(""), "\"\"");
3374 }
3375
3376 #[test]
3377 fn js_string_html_script_close() {
3378 let result = js_string("</script><img onerror=alert(1)>");
3380 assert!(result.starts_with('"'));
3381 let decoded: String = serde_json::from_str(&result).unwrap();
3383 assert_eq!(decoded, "</script><img onerror=alert(1)>");
3384 }
3385
3386 #[test]
3387 fn js_string_very_long() {
3388 let long = "a".repeat(100_000);
3389 let result = js_string(&long);
3390 assert!(result.len() >= 100_002); }
3392
3393 #[test]
3396 fn url_allows_http() {
3397 assert!(validate_url("http://example.com", false).is_ok());
3398 }
3399
3400 #[test]
3401 fn url_allows_https() {
3402 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
3403 }
3404
3405 #[test]
3406 fn url_allows_http_localhost() {
3407 assert!(validate_url("http://localhost:3000", false).is_ok());
3408 }
3409
3410 #[test]
3411 fn url_blocks_file_by_default() {
3412 let err = validate_url("file:///etc/passwd", false).unwrap_err();
3413 assert!(err.contains("file"), "error should mention the file scheme");
3414 }
3415
3416 #[test]
3417 fn url_allows_file_when_opted_in() {
3418 assert!(validate_url("file:///tmp/test.html", true).is_ok());
3419 }
3420
3421 #[test]
3422 fn url_blocks_javascript() {
3423 assert!(validate_url("javascript:alert(1)", false).is_err());
3424 }
3425
3426 #[test]
3427 fn url_blocks_javascript_case_insensitive() {
3428 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
3429 }
3430
3431 #[test]
3432 fn url_blocks_data_scheme() {
3433 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
3434 }
3435
3436 #[test]
3437 fn url_blocks_vbscript() {
3438 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
3439 }
3440
3441 #[test]
3442 fn url_rejects_invalid() {
3443 assert!(validate_url("not a url at all", false).is_err());
3444 }
3445
3446 #[test]
3447 fn url_strips_control_chars() {
3448 let input = format!("http://example{}com", '\0');
3450 assert!(validate_url(&input, false).is_ok());
3451 }
3452
3453 #[test]
3456 fn css_color_valid_hex() {
3457 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
3458 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
3459 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
3460 }
3461
3462 #[test]
3463 fn css_color_valid_rgb() {
3464 assert_eq!(
3465 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
3466 "rgb(255, 0, 0)"
3467 );
3468 assert_eq!(
3469 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
3470 "rgba(0, 0, 0, 0.5)"
3471 );
3472 }
3473
3474 #[test]
3475 fn css_color_valid_named() {
3476 assert_eq!(sanitize_css_color("red").unwrap(), "red");
3477 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
3478 }
3479
3480 #[test]
3481 fn css_color_valid_hsl() {
3482 assert_eq!(
3483 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
3484 "hsl(120, 50%, 50%)"
3485 );
3486 }
3487
3488 #[test]
3489 fn css_color_rejects_too_long() {
3490 let long = "a".repeat(101);
3491 assert!(sanitize_css_color(&long).is_err());
3492 }
3493
3494 #[test]
3495 fn css_color_rejects_backslash_escapes() {
3496 assert!(sanitize_css_color(r"red\00").is_err());
3497 assert!(sanitize_css_color(r"\72\65\64").is_err());
3498 }
3499
3500 #[test]
3501 fn css_color_rejects_url_injection() {
3502 assert!(sanitize_css_color("url(http://evil.com)").is_err());
3503 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
3504 }
3505
3506 #[test]
3507 fn css_color_rejects_expression_injection() {
3508 assert!(sanitize_css_color("expression(alert(1))").is_err());
3509 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
3510 }
3511
3512 #[test]
3513 fn css_color_rejects_import() {
3514 assert!(sanitize_css_color("@import url(evil.css)").is_err());
3515 }
3516
3517 #[test]
3518 fn css_color_rejects_semicolons_and_braces() {
3519 assert!(sanitize_css_color("red; background: url(evil)").is_err());
3520 assert!(sanitize_css_color("red} body { color: blue").is_err());
3521 }
3522
3523 #[test]
3524 fn css_color_rejects_special_chars() {
3525 assert!(sanitize_css_color("red<script>").is_err());
3526 assert!(sanitize_css_color("red\"onload=alert").is_err());
3527 assert!(sanitize_css_color("red'onclick=alert").is_err());
3528 }
3529
3530 #[test]
3531 fn css_color_trims_whitespace() {
3532 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
3533 }
3534
3535 #[test]
3536 fn css_color_empty_string() {
3537 assert_eq!(sanitize_css_color("").unwrap(), "");
3538 }
3539}