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, process \
1792 enumeration, 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`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
1802 - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
1803 - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
1804 - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
1805 - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
1806 - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
1807 - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
1808 - `event_bus_clear`: Clear the event bus capture buffer.",
1809 annotations(
1810 read_only_hint = true,
1811 destructive_hint = false,
1812 idempotent_hint = true,
1813 open_world_hint = false
1814 )
1815 )]
1816 async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
1817 self.track_tool_call();
1818 if !self.state.privacy.is_tool_enabled("introspect") {
1819 return tool_disabled("introspect");
1820 }
1821
1822 match params.action {
1823 IntrospectAction::CommandTimings => {
1824 let mut stats = self.state.command_timings.all_stats();
1825 if let Some(threshold) = params.slow_threshold_ms {
1826 stats.retain(|s| s.avg_ms >= threshold);
1827 }
1828 let result = serde_json::json!({
1829 "commands": stats,
1830 "total_commands_profiled": self.state.command_timings.all_stats().len(),
1831 "slow_threshold_ms": params.slow_threshold_ms,
1832 });
1833 json_result(&result)
1834 }
1835 IntrospectAction::Coverage => {
1836 let registered: Vec<String> = self
1837 .state
1838 .registry
1839 .list()
1840 .iter()
1841 .map(|c| c.name.clone())
1842 .collect();
1843
1844 let code = "return window.__VICTAURI__?.getIpcLog()";
1845 let invoked: std::collections::HashSet<String> = match self
1846 .eval_with_return(code, params.webview_label.as_deref())
1847 .await
1848 {
1849 Ok(json_str) => {
1850 if let Ok(entries) =
1851 serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
1852 {
1853 entries
1854 .iter()
1855 .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
1856 .map(String::from)
1857 .collect()
1858 } else {
1859 std::collections::HashSet::new()
1860 }
1861 }
1862 Err(_) => std::collections::HashSet::new(),
1863 };
1864
1865 let uncovered: Vec<&String> = registered
1866 .iter()
1867 .filter(|cmd| !invoked.contains(cmd.as_str()))
1868 .collect();
1869
1870 let coverage_pct = if registered.is_empty() {
1871 100.0
1872 } else {
1873 let covered = registered.len() - uncovered.len();
1874 (covered as f64 / registered.len() as f64) * 100.0
1875 };
1876
1877 let result = serde_json::json!({
1878 "registered_commands": registered.len(),
1879 "invoked_commands": invoked.len(),
1880 "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
1881 "uncovered": uncovered,
1882 "invoked_not_registered": invoked.iter()
1883 .filter(|cmd| !registered.contains(cmd))
1884 .collect::<Vec<_>>(),
1885 });
1886 json_result(&result)
1887 }
1888 IntrospectAction::ContractRecord => {
1889 let Some(command) = params.command else {
1890 return missing_param("command", "contract_record");
1891 };
1892 let args_json = params.args.unwrap_or(serde_json::json!({}));
1893 let args_str =
1894 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
1895 let code = format!(
1896 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
1897 js_string(&command)
1898 );
1899 match self
1900 .eval_with_return(&code, params.webview_label.as_deref())
1901 .await
1902 {
1903 Ok(result_str) => {
1904 let value: serde_json::Value = serde_json::from_str(&result_str)
1905 .unwrap_or(serde_json::Value::String(result_str.clone()));
1906 let shape = crate::introspection::JsonShape::from_value(&value);
1907 let sample = if result_str.len() > 4096 {
1908 format!("{}...(truncated)", &result_str[..4096])
1909 } else {
1910 result_str
1911 };
1912 let baseline = crate::introspection::ContractBaseline {
1913 command: command.clone(),
1914 args: args_json,
1915 shape: shape.clone(),
1916 sample,
1917 recorded_at: chrono_now(),
1918 };
1919 self.state.contract_store.record(baseline);
1920 let result = serde_json::json!({
1921 "recorded": true,
1922 "command": command,
1923 "shape_type": shape.type_name(),
1924 });
1925 json_result(&result)
1926 }
1927 Err(e) => tool_error(format!(
1928 "failed to invoke '{command}' for contract recording: {e}"
1929 )),
1930 }
1931 }
1932 IntrospectAction::ContractCheck => {
1933 let baselines = self.state.contract_store.all();
1934 if baselines.is_empty() {
1935 return json_result(&serde_json::json!({
1936 "checked": 0,
1937 "message": "no contract baselines recorded — use contract_record first",
1938 }));
1939 }
1940 let mut results = Vec::new();
1941 for baseline in &baselines {
1942 let args_str =
1943 serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
1944 let code = format!(
1945 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
1946 js_string(&baseline.command)
1947 );
1948 match self
1949 .eval_with_return(&code, params.webview_label.as_deref())
1950 .await
1951 {
1952 Ok(result_str) => {
1953 let value: serde_json::Value = serde_json::from_str(&result_str)
1954 .unwrap_or(serde_json::Value::String(result_str));
1955 let current_shape = crate::introspection::JsonShape::from_value(&value);
1956 let drift = crate::introspection::diff_shapes(
1957 &baseline.shape,
1958 ¤t_shape,
1959 &baseline.command,
1960 );
1961 results.push(drift);
1962 }
1963 Err(e) => {
1964 results.push(crate::introspection::ContractDrift {
1965 command: baseline.command.clone(),
1966 new_fields: Vec::new(),
1967 removed_fields: Vec::new(),
1968 type_changes: Vec::new(),
1969 shape_matches: false,
1970 });
1971 tracing::warn!(
1972 command = %baseline.command,
1973 error = %e,
1974 "contract check invocation failed"
1975 );
1976 }
1977 }
1978 }
1979 let passing = results.iter().filter(|r| r.shape_matches).count();
1980 let result = serde_json::json!({
1981 "checked": results.len(),
1982 "passing": passing,
1983 "failing": results.len() - passing,
1984 "contracts": results,
1985 });
1986 json_result(&result)
1987 }
1988 IntrospectAction::ContractList => {
1989 let baselines = self.state.contract_store.all();
1990 let result = serde_json::json!({
1991 "count": baselines.len(),
1992 "baselines": baselines.iter().map(|b| serde_json::json!({
1993 "command": b.command,
1994 "shape_type": b.shape.type_name(),
1995 "recorded_at": b.recorded_at,
1996 })).collect::<Vec<_>>(),
1997 });
1998 json_result(&result)
1999 }
2000 IntrospectAction::ContractClear => {
2001 let cleared = self.state.contract_store.clear();
2002 json_result(&serde_json::json!({
2003 "cleared": cleared,
2004 }))
2005 }
2006 IntrospectAction::StartupTiming => {
2007 let phases = self.state.startup_timeline.report();
2008 let result = serde_json::json!({
2009 "phases": phases,
2010 "total_ms": self.state.startup_timeline.total_ms(),
2011 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2012 });
2013 json_result(&result)
2014 }
2015 IntrospectAction::Capabilities => {
2016 let config = self.bridge.tauri_config();
2017 let live_windows = self.bridge.list_window_labels();
2018
2019 let result = serde_json::json!({
2020 "app": {
2021 "identifier": config.get("identifier"),
2022 "product_name": config.get("product_name"),
2023 "version": config.get("version"),
2024 },
2025 "security": config.get("security"),
2026 "configured_windows": config.get("windows"),
2027 "live_windows": live_windows,
2028 "configured_plugins": config.get("plugins"),
2029 "victauri": {
2030 "registered_commands": self.state.registry.list().len(),
2031 "auth_enabled": self.state.privacy.redaction_enabled,
2032 "privacy_profile": format!("{:?}", self.state.privacy.profile),
2033 "disabled_tools": &self.state.privacy.disabled_tools,
2034 },
2035 });
2036 json_result(&result)
2037 }
2038 #[allow(unused_variables)]
2039 IntrospectAction::DbHealth => {
2040 #[cfg(feature = "sqlite")]
2041 {
2042 let db_path = params.db_path.clone();
2043 match self.run_db_health(db_path.as_deref()).await {
2044 Ok(health) => json_result(&health),
2045 Err(e) => tool_error(format!("db_health failed: {e}")),
2046 }
2047 }
2048 #[cfg(not(feature = "sqlite"))]
2049 {
2050 tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2051 }
2052 }
2053 IntrospectAction::PluginState => {
2054 let recording_active = self.state.recorder.is_recording();
2055 let recording_events = self.state.recorder.event_count();
2056 let result = serde_json::json!({
2057 "event_log": {
2058 "size": self.state.event_log.len(),
2059 "capacity": self.state.event_log.capacity(),
2060 },
2061 "registry": {
2062 "commands_registered": self.state.registry.list().len(),
2063 },
2064 "recording": {
2065 "active": recording_active,
2066 "events_captured": recording_events,
2067 },
2068 "faults": {
2069 "active_rules": self.state.fault_registry.list().len(),
2070 },
2071 "contracts": {
2072 "baselines_recorded": self.state.contract_store.all().len(),
2073 },
2074 "timings": {
2075 "commands_profiled": self.state.command_timings.all_stats().len(),
2076 },
2077 "event_bus": {
2078 "captured_events": self.state.event_bus.len(),
2079 },
2080 "tasks": {
2081 "total": self.state.task_tracker.list().len(),
2082 "active": self.state.task_tracker.active_count(),
2083 },
2084 "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2085 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2086 "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2087 });
2088 json_result(&result)
2089 }
2090 IntrospectAction::Processes => {
2091 let pid = std::process::id();
2092 let uptime = self.state.started_at.elapsed();
2093 let children = crate::introspection::enumerate_child_processes();
2094 let host_memory = crate::memory::current_stats();
2095
2096 let result = serde_json::json!({
2097 "host": {
2098 "pid": pid,
2099 "uptime_secs": uptime.as_secs(),
2100 "platform": std::env::consts::OS,
2101 "arch": std::env::consts::ARCH,
2102 "memory": host_memory,
2103 },
2104 "children": children.iter().map(|c| serde_json::json!({
2105 "pid": c.pid,
2106 "name": c.name,
2107 "memory_bytes": c.memory_bytes,
2108 })).collect::<Vec<_>>(),
2109 "child_count": children.len(),
2110 "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2111 });
2112 json_result(&result)
2113 }
2114 IntrospectAction::PluginTasks => {
2115 let tasks = self.state.task_tracker.list();
2116 let active = self.state.task_tracker.active_count();
2117 let result = serde_json::json!({
2118 "total": tasks.len(),
2119 "active": active,
2120 "finished": tasks.len() - active,
2121 "tasks": tasks,
2122 });
2123 json_result(&result)
2124 }
2125 IntrospectAction::EventBus => {
2126 let tauri_events = self.state.event_bus.events();
2127 let app_events = self.state.event_log.snapshot();
2128 let result = serde_json::json!({
2129 "tauri_events": {
2130 "count": tauri_events.len(),
2131 "events": tauri_events,
2132 },
2133 "app_events": {
2134 "count": app_events.len(),
2135 "capacity": self.state.event_log.capacity(),
2136 "events": app_events,
2137 },
2138 });
2139 json_result(&result)
2140 }
2141 IntrospectAction::EventBusClear => {
2142 let tauri_cleared = self.state.event_bus.clear();
2143 self.state.event_log.clear();
2144 json_result(&serde_json::json!({
2145 "tauri_events_cleared": tauri_cleared,
2146 "app_events_cleared": true,
2147 }))
2148 }
2149 }
2150 }
2151
2152 #[tool(
2155 description = "Inject faults into Tauri IPC commands at the Rust layer for chaos engineering. \
2156 Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2157 CDP cannot inject failures at the backend — it can only observe the frontend.\n\n\
2158 Actions:\n\
2159 - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2160 - `list`: List all active fault injection rules.\n\
2161 - `clear`: Remove a specific fault rule (requires `command`).\n\
2162 - `clear_all`: Remove all fault rules.",
2163 annotations(
2164 read_only_hint = false,
2165 destructive_hint = true,
2166 idempotent_hint = false,
2167 open_world_hint = false
2168 )
2169 )]
2170 async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2171 self.track_tool_call();
2172 if !self.state.privacy.is_tool_enabled("fault") {
2173 return tool_disabled("fault");
2174 }
2175
2176 match params.action {
2177 FaultAction::Inject => {
2178 let Some(command) = params.command else {
2179 return missing_param("command", "inject");
2180 };
2181 let Some(fault_kind) = params.fault_type else {
2182 return missing_param("fault_type", "inject");
2183 };
2184 let fault_type = match fault_kind {
2185 FaultKind::Delay => {
2186 let delay_ms = params.delay_ms.unwrap_or(1000);
2187 crate::introspection::FaultType::Delay { delay_ms }
2188 }
2189 FaultKind::Error => {
2190 let message = params
2191 .error_message
2192 .unwrap_or_else(|| "injected fault".to_string());
2193 crate::introspection::FaultType::Error { message }
2194 }
2195 FaultKind::Drop => crate::introspection::FaultType::Drop,
2196 FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2197 };
2198 let config = crate::introspection::FaultConfig {
2199 command: command.clone(),
2200 fault_type: fault_type.clone(),
2201 trigger_count: 0,
2202 max_triggers: params.max_triggers.unwrap_or(0),
2203 created_at: std::time::Instant::now(),
2204 };
2205 self.state.fault_registry.inject(config);
2206 let result = serde_json::json!({
2207 "injected": true,
2208 "command": command,
2209 "fault_type": fault_type,
2210 "max_triggers": params.max_triggers.unwrap_or(0),
2211 });
2212 json_result(&result)
2213 }
2214 FaultAction::List => {
2215 let faults = self.state.fault_registry.list();
2216 let result = serde_json::json!({
2217 "count": faults.len(),
2218 "faults": faults.iter().map(|f| serde_json::json!({
2219 "command": f.command,
2220 "fault_type": f.fault_type,
2221 "trigger_count": f.trigger_count,
2222 "max_triggers": f.max_triggers,
2223 })).collect::<Vec<_>>(),
2224 });
2225 json_result(&result)
2226 }
2227 FaultAction::Clear => {
2228 let Some(command) = params.command else {
2229 return missing_param("command", "clear");
2230 };
2231 let removed = self.state.fault_registry.clear(&command);
2232 json_result(&serde_json::json!({
2233 "removed": removed,
2234 "command": command,
2235 }))
2236 }
2237 FaultAction::ClearAll => {
2238 let removed = self.state.fault_registry.clear_all();
2239 json_result(&serde_json::json!({
2240 "removed": removed,
2241 }))
2242 }
2243 }
2244 }
2245
2246 #[tool(
2249 description = "Correlate recent activity across all layers into a coherent narrative. \
2250 CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
2251 + window events across the Rust backend and webview simultaneously.\n\n\
2252 Actions:\n\
2253 - `summary`: High-level activity summary for the last N seconds (default 30). \
2254 Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
2255 - `last_action`: Correlate the most recent burst of events into a causal timeline \
2256 (e.g. 'IPC call → DOM update → console.log').\n\
2257 - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
2258 annotations(
2259 read_only_hint = true,
2260 destructive_hint = false,
2261 idempotent_hint = true,
2262 open_world_hint = false
2263 )
2264 )]
2265 async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
2266 self.track_tool_call();
2267 if !self.state.privacy.is_tool_enabled("explain") {
2268 return tool_disabled("explain");
2269 }
2270
2271 match params.action {
2272 ExplainAction::Summary => {
2273 let secs = params.seconds.unwrap_or(30);
2274 let since = chrono::Utc::now()
2275 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2276 let events = self.state.event_log.since(since);
2277
2278 let mut ipc_count = 0u64;
2279 let mut dom_mutations = 0u64;
2280 let mut state_changes = 0u64;
2281 let mut window_events = 0u64;
2282 let mut interactions = 0u64;
2283 let mut top_commands: HashMap<String, u64> = HashMap::new();
2284 let mut errors: Vec<String> = Vec::new();
2285
2286 for event in &events {
2287 match event {
2288 victauri_core::AppEvent::Ipc(call) => {
2289 ipc_count += 1;
2290 *top_commands.entry(call.command.clone()).or_insert(0) += 1;
2291 if let victauri_core::IpcResult::Err(e) = &call.result {
2292 errors.push(format!("IPC {}: {e}", call.command));
2293 }
2294 }
2295 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2296 dom_mutations += u64::from(*mutation_count)
2297 }
2298 victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
2299 victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
2300 victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
2301 _ => {}
2302 }
2303 }
2304
2305 let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
2306 sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
2307 let top: Vec<_> = sorted_cmds.iter().take(5).collect();
2308
2309 let narrative = format!(
2310 "{ipc_count} IPC call{} in the last {secs}s{}. \
2311 {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
2312 {window_events} window event{}. {}.",
2313 if ipc_count == 1 { "" } else { "s" },
2314 if top.is_empty() {
2315 String::new()
2316 } else {
2317 format!(
2318 ", dominated by {}",
2319 top.iter()
2320 .map(|(cmd, n)| format!("{cmd} ({n}x)"))
2321 .collect::<Vec<_>>()
2322 .join(", ")
2323 )
2324 },
2325 if dom_mutations == 1 { "" } else { "s" },
2326 if interactions == 1 { "" } else { "s" },
2327 if window_events == 1 { "" } else { "s" },
2328 if errors.is_empty() {
2329 "No errors".to_string()
2330 } else {
2331 format!(
2332 "{} error{}",
2333 errors.len(),
2334 if errors.len() == 1 { "" } else { "s" }
2335 )
2336 },
2337 );
2338
2339 let result = serde_json::json!({
2340 "time_window_secs": secs,
2341 "total_events": events.len(),
2342 "ipc_calls": ipc_count,
2343 "dom_mutations": dom_mutations,
2344 "state_changes": state_changes,
2345 "window_events": window_events,
2346 "interactions": interactions,
2347 "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
2348 serde_json::json!({"command": cmd, "count": n})
2349 }).collect::<Vec<_>>(),
2350 "errors": errors,
2351 "narrative": narrative,
2352 });
2353 json_result(&result)
2354 }
2355 ExplainAction::LastAction => {
2356 let secs = params.seconds.unwrap_or(5);
2357 let since = chrono::Utc::now()
2358 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2359 let events = self.state.event_log.since(since);
2360
2361 let timeline: Vec<serde_json::Value> = events
2362 .iter()
2363 .map(|event| match event {
2364 victauri_core::AppEvent::Ipc(call) => {
2365 serde_json::json!({
2366 "time": call.timestamp.to_rfc3339_opts(
2367 chrono::SecondsFormat::Millis, true
2368 ),
2369 "type": "ipc",
2370 "detail": format!(
2371 "{} {} ({}ms)",
2372 call.command,
2373 call.result,
2374 call.duration_ms.unwrap_or(0)
2375 ),
2376 })
2377 }
2378 victauri_core::AppEvent::DomMutation {
2379 timestamp,
2380 mutation_count,
2381 webview_label,
2382 } => {
2383 serde_json::json!({
2384 "time": timestamp.to_rfc3339_opts(
2385 chrono::SecondsFormat::Millis, true
2386 ),
2387 "type": "dom_mutation",
2388 "detail": format!(
2389 "{mutation_count} element{} updated in {webview_label}",
2390 if *mutation_count == 1 { "" } else { "s" }
2391 ),
2392 })
2393 }
2394 victauri_core::AppEvent::DomInteraction {
2395 timestamp,
2396 action,
2397 selector,
2398 ..
2399 } => {
2400 serde_json::json!({
2401 "time": timestamp.to_rfc3339_opts(
2402 chrono::SecondsFormat::Millis, true
2403 ),
2404 "type": "interaction",
2405 "detail": format!("{action} on {selector}"),
2406 })
2407 }
2408 victauri_core::AppEvent::StateChange {
2409 timestamp,
2410 key,
2411 caused_by,
2412 } => {
2413 serde_json::json!({
2414 "time": timestamp.to_rfc3339_opts(
2415 chrono::SecondsFormat::Millis, true
2416 ),
2417 "type": "state_change",
2418 "detail": format!(
2419 "{key} changed{}",
2420 caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
2421 ),
2422 })
2423 }
2424 victauri_core::AppEvent::WindowEvent {
2425 timestamp,
2426 label,
2427 event,
2428 } => {
2429 serde_json::json!({
2430 "time": timestamp.to_rfc3339_opts(
2431 chrono::SecondsFormat::Millis, true
2432 ),
2433 "type": "window_event",
2434 "detail": format!("{event} on window '{label}'"),
2435 })
2436 }
2437 _ => {
2438 serde_json::json!({
2439 "time": event.timestamp().to_rfc3339_opts(
2440 chrono::SecondsFormat::Millis, true
2441 ),
2442 "type": "other",
2443 "detail": "unknown event type",
2444 })
2445 }
2446 })
2447 .collect();
2448
2449 let narrative = if timeline.is_empty() {
2450 format!("No activity in the last {secs}s.")
2451 } else {
2452 let parts: Vec<String> = timeline
2453 .iter()
2454 .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
2455 .map(String::from)
2456 .collect();
2457 parts.join(" → ")
2458 };
2459
2460 let result = serde_json::json!({
2461 "time_window_secs": secs,
2462 "event_count": timeline.len(),
2463 "timeline": timeline,
2464 "narrative": narrative,
2465 });
2466 json_result(&result)
2467 }
2468 ExplainAction::Diff => {
2469 let secs = params.seconds.unwrap_or(10);
2470 let since = chrono::Utc::now()
2471 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2472 let events = self.state.event_log.since(since);
2473
2474 let mut ipc_commands: Vec<String> = Vec::new();
2475 let mut dom_changes = 0u64;
2476 let mut error_count = 0u64;
2477 let mut interaction_count = 0u64;
2478
2479 for event in &events {
2480 match event {
2481 victauri_core::AppEvent::Ipc(call) => {
2482 ipc_commands.push(call.command.clone());
2483 if matches!(call.result, victauri_core::IpcResult::Err(_)) {
2484 error_count += 1;
2485 }
2486 }
2487 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2488 dom_changes += u64::from(*mutation_count)
2489 }
2490 victauri_core::AppEvent::DomInteraction { .. } => {
2491 interaction_count += 1;
2492 }
2493 _ => {}
2494 }
2495 }
2496
2497 ipc_commands.dedup();
2498
2499 let result = serde_json::json!({
2500 "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2501 "time_window_secs": secs,
2502 "total_events": events.len(),
2503 "ipc_calls_made": ipc_commands.len(),
2504 "unique_commands": ipc_commands,
2505 "dom_elements_changed": dom_changes,
2506 "interactions": interaction_count,
2507 "errors": error_count,
2508 });
2509 json_result(&result)
2510 }
2511 }
2512 }
2513}
2514
2515impl VictauriMcpHandler {
2516 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
2518 Self {
2519 state,
2520 bridge,
2521 subscriptions: Arc::new(Mutex::new(HashSet::new())),
2522 bridge_checked: Arc::new(AtomicBool::new(false)),
2523 }
2524 }
2525
2526 pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
2527 self.state.privacy.is_tool_enabled(name)
2528 }
2529
2530 pub(crate) async fn execute_tool(
2531 &self,
2532 name: &str,
2533 args: serde_json::Value,
2534 ) -> Result<CallToolResult, rest::ToolCallError> {
2535 if !self.state.privacy.is_tool_enabled(name) {
2536 return Ok(tool_disabled(name));
2537 }
2538 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2539 let start = std::time::Instant::now();
2540 tracing::debug!(tool = %name, "REST tool invocation started");
2541
2542 let result = match name {
2543 "eval_js" => {
2544 let p: EvalJsParams = Self::parse_args(args)?;
2545 self.eval_js(Parameters(p)).await
2546 }
2547 "dom_snapshot" => {
2548 let p: SnapshotParams = Self::parse_args(args)?;
2549 self.dom_snapshot(Parameters(p)).await
2550 }
2551 "find_elements" => {
2552 let p: FindElementsParams = Self::parse_args(args)?;
2553 self.find_elements(Parameters(p)).await
2554 }
2555 "invoke_command" => {
2556 let p: InvokeCommandParams = Self::parse_args(args)?;
2557 self.invoke_command(Parameters(p)).await
2558 }
2559 "screenshot" => {
2560 let p: ScreenshotParams = Self::parse_args(args)?;
2561 self.screenshot(Parameters(p)).await
2562 }
2563 "verify_state" => {
2564 let p: VerifyStateParams = Self::parse_args(args)?;
2565 self.verify_state(Parameters(p)).await
2566 }
2567 "detect_ghost_commands" => {
2568 let p: GhostCommandParams = Self::parse_args(args)?;
2569 self.detect_ghost_commands(Parameters(p)).await
2570 }
2571 "check_ipc_integrity" => {
2572 let p: IpcIntegrityParams = Self::parse_args(args)?;
2573 self.check_ipc_integrity(Parameters(p)).await
2574 }
2575 "wait_for" => {
2576 let p: WaitForParams = Self::parse_args(args)?;
2577 self.wait_for(Parameters(p)).await
2578 }
2579 "assert_semantic" => {
2580 let p: SemanticAssertParams = Self::parse_args(args)?;
2581 self.assert_semantic(Parameters(p)).await
2582 }
2583 "resolve_command" => {
2584 let p: ResolveCommandParams = Self::parse_args(args)?;
2585 self.resolve_command(Parameters(p)).await
2586 }
2587 "get_registry" => {
2588 let p: RegistryParams = Self::parse_args(args)?;
2589 self.get_registry(Parameters(p)).await
2590 }
2591 "get_memory_stats" => self.get_memory_stats().await,
2592 "get_plugin_info" => self.get_plugin_info().await,
2593 "get_diagnostics" => {
2594 let p: DiagnosticsParams = Self::parse_args(args)?;
2595 self.get_diagnostics(Parameters(p)).await
2596 }
2597 "app_info" => self.app_info().await,
2598 "list_app_dir" => {
2599 let p: ListAppDirParams = Self::parse_args(args)?;
2600 self.list_app_dir(Parameters(p)).await
2601 }
2602 "read_app_file" => {
2603 let p: ReadAppFileParams = Self::parse_args(args)?;
2604 self.read_app_file(Parameters(p)).await
2605 }
2606 #[cfg(feature = "sqlite")]
2607 "query_db" => {
2608 let p: QueryDbParams = Self::parse_args(args)?;
2609 self.query_db(Parameters(p)).await
2610 }
2611 "interact" => {
2612 let p: InteractParams = Self::parse_args(args)?;
2613 self.interact(Parameters(p)).await
2614 }
2615 "input" => {
2616 let p: InputParams = Self::parse_args(args)?;
2617 self.input(Parameters(p)).await
2618 }
2619 "window" => {
2620 let p: WindowParams = Self::parse_args(args)?;
2621 self.window(Parameters(p)).await
2622 }
2623 "storage" => {
2624 let p: StorageParams = Self::parse_args(args)?;
2625 self.storage(Parameters(p)).await
2626 }
2627 "navigate" => {
2628 let p: NavigateParams = Self::parse_args(args)?;
2629 self.navigate(Parameters(p)).await
2630 }
2631 "recording" => {
2632 let p: RecordingParams = Self::parse_args(args)?;
2633 self.recording(Parameters(p)).await
2634 }
2635 "inspect" => {
2636 let p: InspectParams = Self::parse_args(args)?;
2637 self.inspect(Parameters(p)).await
2638 }
2639 "css" => {
2640 let p: CssParams = Self::parse_args(args)?;
2641 self.css(Parameters(p)).await
2642 }
2643 "logs" => {
2644 let p: LogsParams = Self::parse_args(args)?;
2645 self.logs(Parameters(p)).await
2646 }
2647 "introspect" => {
2648 let p: IntrospectParams = Self::parse_args(args)?;
2649 self.introspect(Parameters(p)).await
2650 }
2651 "fault" => {
2652 let p: FaultParams = Self::parse_args(args)?;
2653 self.fault(Parameters(p)).await
2654 }
2655 "explain" => {
2656 let p: ExplainParams = Self::parse_args(args)?;
2657 self.explain(Parameters(p)).await
2658 }
2659 _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
2660 };
2661
2662 let elapsed = start.elapsed();
2663 tracing::debug!(
2664 tool = %name,
2665 elapsed_ms = elapsed.as_millis() as u64,
2666 "REST tool invocation completed"
2667 );
2668
2669 if self.state.privacy.redaction_enabled {
2670 Ok(Self::redact_result(result, &self.state.privacy))
2671 } else {
2672 Ok(result)
2673 }
2674 }
2675
2676 fn parse_args<T: serde::de::DeserializeOwned>(
2677 args: serde_json::Value,
2678 ) -> Result<T, rest::ToolCallError> {
2679 serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
2680 }
2681
2682 fn redact_result(
2683 mut result: CallToolResult,
2684 privacy: &crate::privacy::PrivacyConfig,
2685 ) -> CallToolResult {
2686 for item in &mut result.content {
2687 if let RawContent::Text(ref mut tc) = item.raw {
2688 tc.text = privacy.redact_output(&tc.text);
2689 }
2690 }
2691 result
2692 }
2693
2694 fn track_tool_call(&self) {
2695 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
2696 }
2697
2698 fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
2699 match dir.unwrap_or(AppDir::Data) {
2700 AppDir::Data => self.bridge.app_data_dir(),
2701 AppDir::Config => self.bridge.app_config_dir(),
2702 AppDir::Log => self.bridge.app_log_dir(),
2703 AppDir::LocalData => self.bridge.app_local_data_dir(),
2704 }
2705 }
2706
2707 fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
2708 let canon_base = std::fs::canonicalize(base)
2709 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
2710 let canon_target = std::fs::canonicalize(target)
2711 .map_err(|e| format!("cannot resolve target path: {e}"))?;
2712 if !canon_target.starts_with(&canon_base) {
2713 return Err("path traversal not allowed".to_string());
2714 }
2715 Ok(())
2716 }
2717
2718 fn list_dir_recursive(
2719 dir: &std::path::Path,
2720 base: &std::path::Path,
2721 depth: u32,
2722 max_depth: u32,
2723 pattern: Option<&str>,
2724 entries: &mut Vec<serde_json::Value>,
2725 ) {
2726 let Ok(read_dir) = std::fs::read_dir(dir) else {
2727 return;
2728 };
2729 for entry in read_dir.flatten() {
2730 let path = entry.path();
2731 if path.is_symlink() {
2732 continue;
2733 }
2734 let name = entry.file_name().to_string_lossy().into_owned();
2735 let relative = path
2736 .strip_prefix(base)
2737 .unwrap_or(&path)
2738 .to_string_lossy()
2739 .into_owned();
2740
2741 if let Some(pat) = pattern
2742 && !Self::matches_glob(&name, pat)
2743 && !path.is_dir()
2744 {
2745 continue;
2746 }
2747
2748 let is_dir = path.is_dir();
2749 let meta = std::fs::metadata(&path).ok();
2750
2751 entries.push(serde_json::json!({
2752 "name": name,
2753 "path": relative,
2754 "is_dir": is_dir,
2755 "size": meta.as_ref().map(std::fs::Metadata::len),
2756 "modified": meta.as_ref()
2757 .and_then(|m| m.modified().ok())
2758 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
2759 .unwrap_or_default().as_secs()),
2760 }));
2761
2762 if is_dir && depth < max_depth {
2763 Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
2764 }
2765 }
2766 }
2767
2768 fn matches_glob(name: &str, pattern: &str) -> bool {
2769 if pattern == "*" {
2770 return true;
2771 }
2772 if let Some(suffix) = pattern.strip_prefix("*.") {
2773 return name.ends_with(&format!(".{suffix}"));
2774 }
2775 if let Some(prefix) = pattern.strip_suffix("*") {
2776 return name.starts_with(prefix);
2777 }
2778 name == pattern
2779 }
2780
2781 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
2782 match self.eval_with_return(code, webview_label).await {
2783 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2784 Err(e) => tool_error(e),
2785 }
2786 }
2787
2788 async fn eval_with_return(
2789 &self,
2790 code: &str,
2791 webview_label: Option<&str>,
2792 ) -> Result<String, String> {
2793 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
2794 .await
2795 }
2796
2797 async fn eval_with_return_timeout(
2798 &self,
2799 code: &str,
2800 webview_label: Option<&str>,
2801 timeout: std::time::Duration,
2802 ) -> Result<String, String> {
2803 self.track_tool_call();
2804 let id = uuid::Uuid::new_v4().to_string();
2805 let (tx, rx) = tokio::sync::oneshot::channel();
2806
2807 {
2808 let mut pending = self.state.pending_evals.lock().await;
2809 if pending.len() >= MAX_PENDING_EVALS {
2810 return Err(format!(
2811 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
2812 ));
2813 }
2814 pending.insert(id.clone(), tx);
2815 }
2816
2817 let code = code.trim();
2821 let needs_return = !code.starts_with("return ")
2822 && !code.starts_with("return;")
2823 && !code.starts_with('{')
2824 && !code.starts_with("if ")
2825 && !code.starts_with("if(")
2826 && !code.starts_with("for ")
2827 && !code.starts_with("for(")
2828 && !code.starts_with("while ")
2829 && !code.starts_with("while(")
2830 && !code.starts_with("switch ")
2831 && !code.starts_with("try ")
2832 && !code.starts_with("const ")
2833 && !code.starts_with("let ")
2834 && !code.starts_with("var ")
2835 && !code.starts_with("function ")
2836 && !code.starts_with("class ")
2837 && !code.starts_with("throw ");
2838 let code = if needs_return {
2839 format!("return {code}")
2840 } else {
2841 code.to_string()
2842 };
2843
2844 let id_js = js_string(&id);
2845 let inject = format!(
2846 r"
2847 (async () => {{
2848 try {{
2849 const __result = await (async () => {{ {code} }})();
2850 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
2851 id: {id_js},
2852 result: JSON.stringify(__result)
2853 }});
2854 }} catch (e) {{
2855 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
2856 id: {id_js},
2857 result: JSON.stringify({{ __error: e.message }})
2858 }});
2859 }}
2860 }})();
2861 "
2862 );
2863
2864 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
2865 self.state.pending_evals.lock().await.remove(&id);
2866 return Err(format!("eval injection failed: {e}"));
2867 }
2868
2869 match tokio::time::timeout(timeout, rx).await {
2870 Ok(Ok(result)) => {
2871 self.check_bridge_version_once();
2872 Ok(result)
2873 }
2874 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
2875 Err(_) => {
2876 self.state.pending_evals.lock().await.remove(&id);
2877 Err(format!("eval timed out after {}s", timeout.as_secs()))
2878 }
2879 }
2880 }
2881
2882 #[cfg(feature = "sqlite")]
2883 async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
2884 let data_dir = self.bridge.app_data_dir()?;
2885 let path = if let Some(p) = db_path {
2886 data_dir.join(p)
2887 } else {
2888 let mut found = None;
2889 if let Ok(entries) = std::fs::read_dir(&data_dir) {
2890 for entry in entries.flatten() {
2891 let p = entry.path();
2892 if p.extension()
2893 .is_some_and(|ext| ext == "db" || ext == "sqlite" || ext == "sqlite3")
2894 {
2895 found = Some(p);
2896 break;
2897 }
2898 }
2899 }
2900 found.ok_or_else(|| "no database found in app data directory".to_string())?
2901 };
2902 Self::safe_within(&data_dir, &path)?;
2903
2904 let path_str = path
2905 .to_str()
2906 .ok_or_else(|| "invalid path encoding".to_string())?
2907 .to_string();
2908
2909 tokio::task::spawn_blocking(move || {
2910 let conn = rusqlite::Connection::open_with_flags(
2911 &path_str,
2912 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
2913 )
2914 .map_err(|e| format!("cannot open database: {e}"))?;
2915
2916 let journal_mode: String = conn
2917 .pragma_query_value(None, "journal_mode", |r| r.get(0))
2918 .unwrap_or_else(|_| "unknown".to_string());
2919
2920 let page_count: i64 = conn
2921 .pragma_query_value(None, "page_count", |r| r.get(0))
2922 .unwrap_or(0);
2923
2924 let page_size: i64 = conn
2925 .pragma_query_value(None, "page_size", |r| r.get(0))
2926 .unwrap_or(0);
2927
2928 let freelist_count: i64 = conn
2929 .pragma_query_value(None, "freelist_count", |r| r.get(0))
2930 .unwrap_or(0);
2931
2932 let wal_checkpoint: String = if journal_mode == "wal" {
2933 let mut info = String::from("n/a");
2934 let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
2935 let busy: i64 = r.get(0)?;
2936 let checkpointed: i64 = r.get(1)?;
2937 let total: i64 = r.get(2)?;
2938 info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
2939 Ok(())
2940 });
2941 info
2942 } else {
2943 "n/a (not WAL mode)".to_string()
2944 };
2945
2946 let integrity: String = conn
2947 .pragma_query_value(None, "quick_check", |r| r.get(0))
2948 .unwrap_or_else(|_| "failed".to_string());
2949
2950 let db_size_bytes = page_count * page_size;
2951 let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
2952
2953 let mut tables = Vec::new();
2954 if let Ok(mut stmt) =
2955 conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
2956 && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
2957 {
2958 for name in rows.flatten() {
2959 let count: i64 = conn
2960 .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
2961 .unwrap_or(0);
2962 tables.push(serde_json::json!({
2963 "name": name,
2964 "row_count": count,
2965 }));
2966 }
2967 }
2968
2969 Ok(serde_json::json!({
2970 "database": path_str,
2971 "journal_mode": journal_mode,
2972 "page_count": page_count,
2973 "page_size": page_size,
2974 "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
2975 "freelist_count": freelist_count,
2976 "wal_checkpoint": wal_checkpoint,
2977 "integrity_check": integrity,
2978 "tables": tables,
2979 }))
2980 })
2981 .await
2982 .map_err(|e| format!("db health task failed: {e}"))?
2983 }
2984
2985 fn check_bridge_version_once(&self) {
2986 if self.bridge_checked.swap(true, Ordering::Relaxed) {
2987 return;
2988 }
2989 let handler = self.clone();
2990 tokio::spawn(async move {
2991 match handler
2992 .eval_with_return_timeout(
2993 "window.__VICTAURI__?.version",
2994 None,
2995 std::time::Duration::from_secs(5),
2996 )
2997 .await
2998 {
2999 Ok(v) => {
3000 let v = v.trim_matches('"');
3001 if v == BRIDGE_VERSION {
3002 tracing::debug!("Bridge version verified: {v}");
3003 } else {
3004 tracing::warn!(
3005 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
3006 );
3007 }
3008 }
3009 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
3010 }
3011 });
3012 }
3013}
3014
3015const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
3016It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
3017(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
3018(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
3019\n\nBACKEND tools (direct Rust access, no webview needed): \
3020'app_info' (app config, directory paths, discovered databases, process info), \
3021'list_app_dir' (browse app data/config/log directories), \
3022'read_app_file' (read files from app directories), \
3023'query_db' (read-only SQLite queries with auto-discovery). \
3024\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
3025'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
3026capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
3027Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
3028capability/security auditing, database diagnostics, plugin state, child process enumeration, \
3029task tracking, and automatic Tauri event bus monitoring. \
3030'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
3031drops, and response corruption into Tauri commands at the Rust layer. \
3032'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
3033activity across IPC + DOM + console + network + window events into a coherent narrative. \
3034\n\nWEBVIEW tools: \
3035'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
3036'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
3037'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
3038\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
3039\n\nCOMPOUND tools with an 'action' parameter: \
3040'window' (get_state, list, manage, resize, move_to, set_title), \
3041'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
3042set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
3043get_events, events_between, get_replay, export, import, replay), \
3044'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
3045\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
3046get_memory_stats, get_plugin_info, get_diagnostics.";
3047
3048impl ServerHandler for VictauriMcpHandler {
3049 fn get_info(&self) -> ServerInfo {
3050 ServerInfo::new(
3051 ServerCapabilities::builder()
3052 .enable_tools()
3053 .enable_resources()
3054 .enable_resources_subscribe()
3055 .build(),
3056 )
3057 .with_instructions(SERVER_INSTRUCTIONS)
3058 }
3059
3060 async fn list_tools(
3061 &self,
3062 _request: Option<PaginatedRequestParams>,
3063 _context: RequestContext<RoleServer>,
3064 ) -> Result<ListToolsResult, ErrorData> {
3065 let all_tools = Self::tool_router().list_all();
3066 let filtered: Vec<Tool> = all_tools
3067 .into_iter()
3068 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
3069 .collect();
3070 Ok(ListToolsResult {
3071 tools: filtered,
3072 ..Default::default()
3073 })
3074 }
3075
3076 async fn call_tool(
3077 &self,
3078 request: CallToolRequestParams,
3079 context: RequestContext<RoleServer>,
3080 ) -> Result<CallToolResult, ErrorData> {
3081 let tool_name: String = request.name.as_ref().to_owned();
3082 if !self.state.privacy.is_tool_enabled(&tool_name) {
3083 tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
3084 return Ok(tool_disabled(&tool_name));
3085 }
3086 self.state
3087 .tool_invocations
3088 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3089 let start = std::time::Instant::now();
3090 tracing::debug!(tool = %tool_name, "tool invocation started");
3091 let ctx = ToolCallContext::new(self, request, context);
3092 let result = Self::tool_router().call(ctx).await;
3093 let elapsed = start.elapsed();
3094 tracing::debug!(
3095 tool = %tool_name,
3096 elapsed_ms = elapsed.as_millis() as u64,
3097 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
3098 "tool invocation completed"
3099 );
3100
3101 if self.state.privacy.redaction_enabled {
3104 result.map(|mut r| {
3105 for item in &mut r.content {
3106 if let RawContent::Text(ref mut tc) = item.raw {
3107 tc.text = self.state.privacy.redact_output(&tc.text);
3108 }
3109 }
3110 r
3111 })
3112 } else {
3113 result
3114 }
3115 }
3116
3117 fn get_tool(&self, name: &str) -> Option<Tool> {
3118 if !self.state.privacy.is_tool_enabled(name) {
3119 return None;
3120 }
3121 Self::tool_router().get(name).cloned()
3122 }
3123
3124 async fn list_resources(
3125 &self,
3126 _request: Option<PaginatedRequestParams>,
3127 _context: RequestContext<RoleServer>,
3128 ) -> Result<ListResourcesResult, ErrorData> {
3129 Ok(ListResourcesResult {
3130 resources: vec![
3131 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
3132 .with_description(
3133 "Live IPC call log — all commands invoked between frontend and backend",
3134 )
3135 .with_mime_type("application/json")
3136 .no_annotation(),
3137 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
3138 .with_description(
3139 "Current state of all Tauri windows — position, size, visibility, focus",
3140 )
3141 .with_mime_type("application/json")
3142 .no_annotation(),
3143 RawResource::new(RESOURCE_URI_STATE, "state")
3144 .with_description(
3145 "Victauri plugin state — event count, registered commands, memory stats",
3146 )
3147 .with_mime_type("application/json")
3148 .no_annotation(),
3149 ],
3150 ..Default::default()
3151 })
3152 }
3153
3154 async fn read_resource(
3155 &self,
3156 request: ReadResourceRequestParams,
3157 _context: RequestContext<RoleServer>,
3158 ) -> Result<ReadResourceResult, ErrorData> {
3159 let uri = &request.uri;
3160 let json = match uri.as_str() {
3161 RESOURCE_URI_IPC_LOG => {
3162 if let Ok(json) = self
3163 .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
3164 .await
3165 {
3166 json
3167 } else {
3168 let calls = self.state.event_log.ipc_calls();
3169 serde_json::to_string_pretty(&calls)
3170 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3171 }
3172 }
3173 RESOURCE_URI_WINDOWS => {
3174 let states = self.bridge.get_window_states(None);
3175 serde_json::to_string_pretty(&states)
3176 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3177 }
3178 RESOURCE_URI_STATE => {
3179 let state_json = serde_json::json!({
3180 "events_captured": self.state.event_log.len(),
3181 "commands_registered": self.state.registry.count(),
3182 "memory": crate::memory::current_stats(),
3183 "port": self.state.port.load(Ordering::Relaxed),
3184 });
3185 serde_json::to_string_pretty(&state_json)
3186 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3187 }
3188 _ => {
3189 return Err(ErrorData::resource_not_found(
3190 format!("unknown resource: {uri}"),
3191 None,
3192 ));
3193 }
3194 };
3195
3196 let json = if self.state.privacy.redaction_enabled {
3197 self.state.privacy.redact_output(&json)
3198 } else {
3199 json
3200 };
3201
3202 Ok(ReadResourceResult::new(vec![ResourceContents::text(
3203 json, uri,
3204 )]))
3205 }
3206
3207 async fn subscribe(
3208 &self,
3209 request: SubscribeRequestParams,
3210 _context: RequestContext<RoleServer>,
3211 ) -> Result<(), ErrorData> {
3212 let uri = &request.uri;
3213 match uri.as_str() {
3214 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
3215 self.subscriptions.lock().await.insert(uri.clone());
3216 tracing::info!("Client subscribed to resource: {uri}");
3217 Ok(())
3218 }
3219 _ => Err(ErrorData::resource_not_found(
3220 format!("unknown resource: {uri}"),
3221 None,
3222 )),
3223 }
3224 }
3225
3226 async fn unsubscribe(
3227 &self,
3228 request: UnsubscribeRequestParams,
3229 _context: RequestContext<RoleServer>,
3230 ) -> Result<(), ErrorData> {
3231 self.subscriptions.lock().await.remove(&request.uri);
3232 tracing::info!("Client unsubscribed from resource: {}", request.uri);
3233 Ok(())
3234 }
3235}
3236
3237#[cfg(test)]
3238mod tests {
3239 use super::*;
3240
3241 #[test]
3242 fn js_string_simple() {
3243 assert_eq!(js_string("hello"), "\"hello\"");
3244 }
3245
3246 #[test]
3247 fn js_string_single_quotes() {
3248 let result = js_string("it's a test");
3249 assert!(result.contains("it's a test"));
3250 }
3251
3252 #[test]
3253 fn js_string_double_quotes() {
3254 let result = js_string(r#"say "hello""#);
3255 assert!(result.contains(r#"\""#));
3256 }
3257
3258 #[test]
3259 fn js_string_backslashes() {
3260 let result = js_string(r"path\to\file");
3261 assert!(result.contains(r"\\"));
3262 }
3263
3264 #[test]
3265 fn js_string_newlines_and_tabs() {
3266 let result = js_string("line1\nline2\ttab");
3267 assert!(result.contains(r"\n"));
3268 assert!(result.contains(r"\t"));
3269 assert!(!result.contains('\n'));
3270 }
3271
3272 #[test]
3273 fn js_string_null_bytes() {
3274 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
3275 let result = js_string(&input);
3276 assert!(result.contains("\\u0000"));
3278 assert!(!result.contains('\0'));
3279 }
3280
3281 #[test]
3282 fn js_string_template_literal_injection() {
3283 let result = js_string("`${alert(1)}`");
3284 assert!(result.starts_with('"'));
3287 assert!(result.ends_with('"'));
3288 }
3289
3290 #[test]
3291 fn js_string_unicode_separators() {
3292 let result = js_string("a\u{2028}b\u{2029}c");
3297 let decoded: String = serde_json::from_str(&result).unwrap();
3299 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
3300 }
3301
3302 #[test]
3303 fn js_string_empty() {
3304 assert_eq!(js_string(""), "\"\"");
3305 }
3306
3307 #[test]
3308 fn js_string_html_script_close() {
3309 let result = js_string("</script><img onerror=alert(1)>");
3311 assert!(result.starts_with('"'));
3312 let decoded: String = serde_json::from_str(&result).unwrap();
3314 assert_eq!(decoded, "</script><img onerror=alert(1)>");
3315 }
3316
3317 #[test]
3318 fn js_string_very_long() {
3319 let long = "a".repeat(100_000);
3320 let result = js_string(&long);
3321 assert!(result.len() >= 100_002); }
3323
3324 #[test]
3327 fn url_allows_http() {
3328 assert!(validate_url("http://example.com", false).is_ok());
3329 }
3330
3331 #[test]
3332 fn url_allows_https() {
3333 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
3334 }
3335
3336 #[test]
3337 fn url_allows_http_localhost() {
3338 assert!(validate_url("http://localhost:3000", false).is_ok());
3339 }
3340
3341 #[test]
3342 fn url_blocks_file_by_default() {
3343 let err = validate_url("file:///etc/passwd", false).unwrap_err();
3344 assert!(err.contains("file"), "error should mention the file scheme");
3345 }
3346
3347 #[test]
3348 fn url_allows_file_when_opted_in() {
3349 assert!(validate_url("file:///tmp/test.html", true).is_ok());
3350 }
3351
3352 #[test]
3353 fn url_blocks_javascript() {
3354 assert!(validate_url("javascript:alert(1)", false).is_err());
3355 }
3356
3357 #[test]
3358 fn url_blocks_javascript_case_insensitive() {
3359 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
3360 }
3361
3362 #[test]
3363 fn url_blocks_data_scheme() {
3364 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
3365 }
3366
3367 #[test]
3368 fn url_blocks_vbscript() {
3369 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
3370 }
3371
3372 #[test]
3373 fn url_rejects_invalid() {
3374 assert!(validate_url("not a url at all", false).is_err());
3375 }
3376
3377 #[test]
3378 fn url_strips_control_chars() {
3379 let input = format!("http://example{}com", '\0');
3381 assert!(validate_url(&input, false).is_ok());
3382 }
3383
3384 #[test]
3387 fn css_color_valid_hex() {
3388 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
3389 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
3390 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
3391 }
3392
3393 #[test]
3394 fn css_color_valid_rgb() {
3395 assert_eq!(
3396 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
3397 "rgb(255, 0, 0)"
3398 );
3399 assert_eq!(
3400 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
3401 "rgba(0, 0, 0, 0.5)"
3402 );
3403 }
3404
3405 #[test]
3406 fn css_color_valid_named() {
3407 assert_eq!(sanitize_css_color("red").unwrap(), "red");
3408 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
3409 }
3410
3411 #[test]
3412 fn css_color_valid_hsl() {
3413 assert_eq!(
3414 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
3415 "hsl(120, 50%, 50%)"
3416 );
3417 }
3418
3419 #[test]
3420 fn css_color_rejects_too_long() {
3421 let long = "a".repeat(101);
3422 assert!(sanitize_css_color(&long).is_err());
3423 }
3424
3425 #[test]
3426 fn css_color_rejects_backslash_escapes() {
3427 assert!(sanitize_css_color(r"red\00").is_err());
3428 assert!(sanitize_css_color(r"\72\65\64").is_err());
3429 }
3430
3431 #[test]
3432 fn css_color_rejects_url_injection() {
3433 assert!(sanitize_css_color("url(http://evil.com)").is_err());
3434 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
3435 }
3436
3437 #[test]
3438 fn css_color_rejects_expression_injection() {
3439 assert!(sanitize_css_color("expression(alert(1))").is_err());
3440 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
3441 }
3442
3443 #[test]
3444 fn css_color_rejects_import() {
3445 assert!(sanitize_css_color("@import url(evil.css)").is_err());
3446 }
3447
3448 #[test]
3449 fn css_color_rejects_semicolons_and_braces() {
3450 assert!(sanitize_css_color("red; background: url(evil)").is_err());
3451 assert!(sanitize_css_color("red} body { color: blue").is_err());
3452 }
3453
3454 #[test]
3455 fn css_color_rejects_special_chars() {
3456 assert!(sanitize_css_color("red<script>").is_err());
3457 assert!(sanitize_css_color("red\"onload=alert").is_err());
3458 assert!(sanitize_css_color("red'onclick=alert").is_err());
3459 }
3460
3461 #[test]
3462 fn css_color_trims_whitespace() {
3463 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
3464 }
3465
3466 #[test]
3467 fn css_color_empty_string() {
3468 assert_eq!(sanitize_css_color("").unwrap(), "");
3469 }
3470}