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