1mod backend_params;
8mod compound_params;
9mod helpers;
10mod introspection_params;
11mod other_params;
12mod rest;
13mod server;
14mod verification_params;
15mod webview_params;
16mod window_params;
17
18use std::collections::{HashMap, HashSet};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use rmcp::handler::server::tool::ToolCallContext;
23use rmcp::handler::server::wrapper::Parameters;
24use rmcp::model::{
25 AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
26 ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
27 ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
28 Tool, UnsubscribeRequestParams,
29};
30use rmcp::service::RequestContext;
31use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
32use tokio::sync::Mutex;
33
34use crate::VictauriState;
35use crate::bridge::WebviewBridge;
36
37use helpers::{
38 RecoveryHint, js_string, json_result, missing_param, sanitize_css_color, sanitize_injected_css,
39 tool_disabled, tool_error, tool_error_with_hint, validate_url,
40};
41
42pub use backend_params::*;
43pub use compound_params::*;
44pub use introspection_params::*;
45pub use other_params::{
46 DiagnosticsParams, FindElementsParams, ResolveCommandParams, SemanticAssertParams,
47 WaitCondition, WaitForParams,
48};
49pub use server::*;
50pub use verification_params::*;
51pub use webview_params::*;
52pub use window_params::*;
53
54pub(crate) const MAX_PENDING_EVALS: usize = 100;
59
60fn chrono_now() -> String {
61 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
62}
63
64const MAX_EVAL_CODE_LEN: usize = 1_000_000;
66
67const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
70
71const PARSE_WATCHDOG_MS: u64 = 750;
76
77const DEFAULT_LOG_LIMIT: usize = 100;
80
81const MAX_LOG_FIELD_BYTES: usize = 4096;
85
86const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
87const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
88const RESOURCE_URI_STATE: &str = "victauri://state";
89
90const BRIDGE_VERSION: &str = "0.5.0";
91
92const SAFE_ENV_PREFIXES: &[&str] = &[
93 "HOME",
94 "USER",
95 "LANG",
96 "LC_",
97 "TERM",
98 "SHELL",
99 "DISPLAY",
100 "XDG_",
101 "TAURI_ENV_",
104 "VICTAURI_",
105 "NODE_ENV",
106 "OS",
107 "HOSTNAME",
108 "PWD",
109 "SHLVL",
110 "LOGNAME",
111];
112
113const SECRET_ENV_SUBSTRINGS: &[&str] = &[
118 "TOKEN",
119 "SECRET",
120 "PASS", "PRIVATE",
122 "CREDENTIAL",
123 "APIKEY",
124 "AUTH",
125 "_KEY",
126 "DSN", "PAT", "JWT",
129 "BEARER",
130 "SESSION",
131 "COOKIE",
132 "SALT",
133 "CERT",
134 "SIGN", "LICENSE",
136];
137
138fn is_safe_env_key(key: &str) -> bool {
141 let upper = key.to_uppercase();
142 SAFE_ENV_PREFIXES
143 .iter()
144 .any(|prefix| upper.starts_with(prefix))
145 && !SECRET_ENV_SUBSTRINGS.iter().any(|s| upper.contains(s))
146}
147
148#[derive(Clone)]
150pub struct VictauriMcpHandler {
151 state: Arc<VictauriState>,
152 bridge: Arc<dyn WebviewBridge>,
153 subscriptions: Arc<Mutex<HashSet<String>>>,
154 bridge_checked: Arc<AtomicBool>,
155 probed_labels: Arc<Mutex<HashSet<String>>>,
156 timed_out_labels: Arc<Mutex<HashSet<String>>>,
160}
161
162#[tool_router]
163impl VictauriMcpHandler {
164 #[tool(
167 description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
168 annotations(
169 read_only_hint = false,
170 destructive_hint = true,
171 idempotent_hint = false,
172 open_world_hint = false
173 )
174 )]
175 async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
176 if !self.state.privacy.is_tool_enabled("eval_js") {
177 return tool_disabled("eval_js");
178 }
179 if params.code.len() > MAX_EVAL_CODE_LEN {
180 return tool_error("code exceeds maximum length (1 MB)");
181 }
182 match self
183 .eval_with_return(¶ms.code, params.webview_label.as_deref())
184 .await
185 {
186 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
187 Err(e) => tool_error(e),
188 }
189 }
190
191 #[tool(
192 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).",
193 annotations(
194 read_only_hint = true,
195 destructive_hint = false,
196 idempotent_hint = true,
197 open_world_hint = false
198 )
199 )]
200 async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
201 let format = params.format.unwrap_or(SnapshotFormat::Compact);
202 let format_str = match format {
203 SnapshotFormat::Compact => "compact",
204 SnapshotFormat::Json => "json",
205 };
206 let code = format!(
207 "return window.__VICTAURI__?.snapshot({})",
208 js_string(format_str)
209 );
210 self.eval_bridge(&code, params.webview_label.as_deref())
211 .await
212 }
213
214 #[tool(
215 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.",
216 annotations(
217 read_only_hint = true,
218 destructive_hint = false,
219 idempotent_hint = true,
220 open_world_hint = false
221 )
222 )]
223 async fn find_elements(
224 &self,
225 Parameters(params): Parameters<FindElementsParams>,
226 ) -> CallToolResult {
227 let mut parts: Vec<String> = Vec::new();
228 if let Some(t) = ¶ms.text {
229 parts.push(format!("text: {}", js_string(t)));
230 }
231 if let Some(r) = ¶ms.role {
232 parts.push(format!("role: {}", js_string(r)));
233 }
234 if let Some(tid) = ¶ms.test_id {
235 parts.push(format!("test_id: {}", js_string(tid)));
236 }
237 if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
238 parts.push(format!("css: {}", js_string(c)));
239 }
240 if let Some(n) = ¶ms.name {
241 parts.push(format!("name: {}", js_string(n)));
242 }
243 if let Some(max) = params.max_results {
244 parts.push(format!("max_results: {max}"));
245 }
246 if let Some(t) = ¶ms.tag {
247 parts.push(format!("tag: {}", js_string(t)));
248 }
249 if let Some(p) = ¶ms.placeholder {
250 parts.push(format!("placeholder: {}", js_string(p)));
251 }
252 if let Some(a) = ¶ms.alt {
253 parts.push(format!("alt: {}", js_string(a)));
254 }
255 if let Some(ta) = ¶ms.title_attr {
256 parts.push(format!("title_attr: {}", js_string(ta)));
257 }
258 if let Some(l) = ¶ms.label {
259 parts.push(format!("label: {}", js_string(l)));
260 }
261 if let Some(true) = params.exact {
262 parts.push("exact: true".to_string());
263 }
264 if let Some(e) = params.enabled {
265 parts.push(format!("enabled: {e}"));
266 }
267 let code = format!(
268 "return window.__VICTAURI__?.findElements({{ {} }})",
269 parts.join(", ")
270 );
271 match self
272 .eval_with_return(&code, params.webview_label.as_deref())
273 .await
274 {
275 Ok(result) => {
276 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
277 && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
278 {
279 return tool_error(err);
280 }
281 CallToolResult::success(vec![Content::text(result)])
282 }
283 Err(e) => tool_error(e),
284 }
285 }
286
287 #[tool(
288 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.",
289 annotations(
290 read_only_hint = false,
291 destructive_hint = true,
292 idempotent_hint = false,
293 open_world_hint = false
294 )
295 )]
296 async fn invoke_command(
297 &self,
298 Parameters(params): Parameters<InvokeCommandParams>,
299 ) -> CallToolResult {
300 if !self.state.privacy.is_invoke_allowed(¶ms.command) {
301 return tool_disabled("invoke_command");
302 }
303 if !self.state.privacy.is_command_allowed(¶ms.command) {
304 return tool_error(format!(
305 "command '{}' is blocked by privacy configuration",
306 params.command
307 ));
308 }
309
310 if let Some(fault) = self.state.fault_registry.check_and_trigger(¶ms.command) {
312 match fault {
313 crate::introspection::FaultType::Delay { delay_ms } => {
314 tracing::info!(
315 command = %params.command,
316 delay_ms = delay_ms,
317 "fault injection: delaying command"
318 );
319 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
320 }
322 crate::introspection::FaultType::Error { ref message } => {
323 tracing::info!(
324 command = %params.command,
325 "fault injection: returning error"
326 );
327 return tool_error(format!(
328 "[FAULT INJECTED] command '{}': {message}",
329 params.command
330 ));
331 }
332 crate::introspection::FaultType::Drop => {
333 tracing::info!(
334 command = %params.command,
335 "fault injection: dropping response"
336 );
337 return CallToolResult::success(vec![Content::text("{}")]);
338 }
339 crate::introspection::FaultType::Corrupt => {
340 tracing::info!(
341 command = %params.command,
342 "fault injection: corrupting response"
343 );
344 let args_json = params.args.unwrap_or(serde_json::json!({}));
346 let args_str =
347 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
348 let code = format!(
349 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
350 js_string(¶ms.command)
351 );
352 if let Ok(result) = self
353 .eval_with_return(&code, params.webview_label.as_deref())
354 .await
355 {
356 let corrupted = format!(
357 "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
358 result.len()
359 );
360 return CallToolResult::success(vec![Content::text(corrupted)]);
361 }
362 return CallToolResult::success(vec![Content::text(
363 "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
364 )]);
365 }
366 }
367 }
368
369 let start = std::time::Instant::now();
371 let args_json = params.args.unwrap_or(serde_json::json!({}));
372 let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
373 let code = format!(
374 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
375 js_string(¶ms.command)
376 );
377 let result = self
378 .eval_with_return(&code, params.webview_label.as_deref())
379 .await;
380 let elapsed = start.elapsed();
381 self.state.command_timings.record(¶ms.command, elapsed);
382
383 match result {
384 Ok(result) => {
385 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
386 && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
387 {
388 return tool_error(format!(
389 "command '{}' returned error: {err}",
390 params.command
391 ));
392 }
393 CallToolResult::success(vec![Content::text(result)])
394 }
395 Err(e) => tool_error(format!("invoke_command failed: {e}")),
396 }
397 }
398
399 #[tool(
400 description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
401 annotations(
402 read_only_hint = true,
403 destructive_hint = false,
404 idempotent_hint = true,
405 open_world_hint = false
406 )
407 )]
408 async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
409 self.track_tool_call();
410 if !self.state.privacy.is_tool_enabled("screenshot") {
411 return tool_disabled("screenshot");
412 }
413 match self
414 .bridge
415 .get_native_handle(params.window_label.as_deref())
416 {
417 Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
418 Ok(png_bytes) => {
419 use base64::Engine;
420 let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
421 CallToolResult::success(vec![Content::image(b64, "image/png")])
422 }
423 Err(e) => tool_error(format!("screenshot capture failed: {e}")),
424 },
425 Err(e) => tool_error(format!("cannot get window handle: {e}")),
426 }
427 }
428
429 #[tool(
430 description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
431 annotations(
432 read_only_hint = true,
433 destructive_hint = false,
434 idempotent_hint = true,
435 open_world_hint = false
436 )
437 )]
438 async fn verify_state(
439 &self,
440 Parameters(params): Parameters<VerifyStateParams>,
441 ) -> CallToolResult {
442 if !self.state.privacy.is_tool_enabled("eval_js") {
443 return tool_disabled("verify_state requires eval_js capability");
444 }
445 let code = format!("return ({})", params.frontend_expr);
446 let frontend_json = match self
447 .eval_with_return(&code, params.webview_label.as_deref())
448 .await
449 {
450 Ok(result) => result,
451 Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
452 };
453
454 let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
455 Ok(v) => v,
456 Err(e) => {
457 return tool_error(format!(
458 "frontend expression did not return valid JSON: {e}"
459 ));
460 }
461 };
462
463 let backend_state = if let Some(state) = params.backend_state {
464 state
465 } else if let Some(ref cmd) = params.backend_command {
466 if !self.state.privacy.is_invoke_allowed(cmd)
470 || !self.state.privacy.is_command_allowed(cmd)
471 {
472 return tool_error(format!(
473 "command '{cmd}' is blocked by privacy configuration"
474 ));
475 }
476 let args = params.backend_args.unwrap_or(serde_json::json!({}));
477 let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
478 let invoke_code = format!(
479 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
480 js_string(cmd)
481 );
482 match self
483 .eval_with_return(&invoke_code, params.webview_label.as_deref())
484 .await
485 {
486 Ok(result) => match serde_json::from_str(&result) {
487 Ok(v) => v,
488 Err(e) => {
489 return tool_error(format!(
490 "backend command '{cmd}' did not return valid JSON: {e}"
491 ));
492 }
493 },
494 Err(e) => {
495 return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
496 }
497 }
498 } else {
499 return tool_error("either backend_state or backend_command must be provided");
500 };
501
502 let result = victauri_core::verify_state(frontend_state, backend_state);
503 json_result(&result)
504 }
505
506 #[tool(
507 description = "Detect ghost commands — commands invoked from the frontend that have no backend handler, or registered backend commands never called. Reads the JS-side IPC interception log, which ACCUMULATES every command seen this session (including earlier test/probe traffic) — so a 'FrontendOnly' result reflects what was invoked at runtime, not necessarily a real frontend bug. For a clean signal, call `logs {action:'clear'}` first, then exercise the app, then run this.",
508 annotations(
509 read_only_hint = true,
510 destructive_hint = false,
511 idempotent_hint = true,
512 open_world_hint = false
513 )
514 )]
515 async fn detect_ghost_commands(
516 &self,
517 Parameters(params): Parameters<GhostCommandParams>,
518 ) -> CallToolResult {
519 let code = "return (window.__VICTAURI__?.getIpcLog() || []).map(function(c){ return (c && c.command) || null; }).filter(function(x){ return x; })";
523 let ipc_json = match self
524 .eval_with_return(code, params.webview_label.as_deref())
525 .await
526 {
527 Ok(r) => r,
528 Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
529 };
530
531 let command_names: Vec<String> = match serde_json::from_str(&ipc_json) {
532 Ok(v) => v,
533 Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
534 };
535 let frontend_commands: Vec<String> = command_names
536 .into_iter()
537 .collect::<std::collections::HashSet<_>>()
538 .into_iter()
539 .collect();
540
541 let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
542 json_result(&report)
543 }
544
545 #[tool(
546 description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
547 annotations(
548 read_only_hint = true,
549 destructive_hint = false,
550 idempotent_hint = true,
551 open_world_hint = false
552 )
553 )]
554 async fn check_ipc_integrity(
555 &self,
556 Parameters(params): Parameters<IpcIntegrityParams>,
557 ) -> CallToolResult {
558 let threshold = params.stale_threshold_ms.unwrap_or(5000);
559 let code = format!(
560 r"return (function() {{
561 var log = window.__VICTAURI__?.getIpcLog() || [];
562 var now = Date.now();
563 var threshold = {threshold};
564 var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
565 var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
566 var errored = log.filter(function(c) {{ return c.status === 'error'; }});
567 var net = window.__VICTAURI__?.getNetworkLog() || [];
568 var warning = null;
569 if (log.length === 0 && net.length > 5) {{
570 warning = 'Zero IPC calls captured but ' + net.length + ' network requests observed. IPC capture may not be working — verify the app uses Tauri IPC via fetch to ipc.localhost.';
571 }}
572 return {{
573 healthy: stale.length === 0 && errored.length === 0,
574 total_calls: log.length,
575 pending_count: pending.length,
576 stale_count: stale.length,
577 error_count: errored.length,
578 stale_calls: stale.slice(0, 20),
579 errored_calls: errored.slice(0, 20),
580 warning: warning
581 }};
582 }})()"
583 );
584 self.eval_bridge(&code, params.webview_label.as_deref())
585 .await
586 }
587
588 #[tool(
589 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).",
590 annotations(
591 read_only_hint = true,
592 destructive_hint = false,
593 idempotent_hint = true,
594 open_world_hint = false
595 )
596 )]
597 async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
598 let value = params
599 .value
600 .as_ref()
601 .map_or_else(|| "null".to_string(), |v| js_string(v));
602 let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(60_000);
603 let poll = params.poll_ms.unwrap_or(200);
604 let code = format!(
605 "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
606 js_string(params.condition.as_str())
607 );
608 let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
609 match self
610 .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
611 .await
612 {
613 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
614 Err(e) => tool_error(e),
615 }
616 }
617
618 #[tool(
619 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.",
620 annotations(
621 read_only_hint = true,
622 destructive_hint = false,
623 idempotent_hint = true,
624 open_world_hint = false
625 )
626 )]
627 async fn assert_semantic(
628 &self,
629 Parameters(params): Parameters<SemanticAssertParams>,
630 ) -> CallToolResult {
631 if !self.state.privacy.is_tool_enabled("eval_js") {
632 return tool_disabled("assert_semantic requires eval_js capability");
633 }
634 let code = format!("return ({})", params.expression);
635 let actual_json = match self
636 .eval_with_return(&code, params.webview_label.as_deref())
637 .await
638 {
639 Ok(result) => result,
640 Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
641 };
642
643 let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
644 Ok(v) => v,
645 Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
646 };
647
648 let assertion = victauri_core::SemanticAssertion {
649 label: params.label,
650 condition: params.condition,
651 expected: params.expected,
652 };
653
654 let result = victauri_core::evaluate_assertion(actual, &assertion);
655 json_result(&result)
656 }
657
658 #[tool(
659 description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
660 annotations(
661 read_only_hint = true,
662 destructive_hint = false,
663 idempotent_hint = true,
664 open_world_hint = false
665 )
666 )]
667 async fn resolve_command(
668 &self,
669 Parameters(params): Parameters<ResolveCommandParams>,
670 ) -> CallToolResult {
671 self.track_tool_call();
672 let limit = params.limit.unwrap_or(5);
673 let mut results = self.state.registry.resolve(¶ms.query);
674 results.truncate(limit);
675 json_result(&results)
676 }
677
678 #[tool(
679 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.",
680 annotations(
681 read_only_hint = true,
682 destructive_hint = false,
683 idempotent_hint = true,
684 open_world_hint = false
685 )
686 )]
687 async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
688 self.track_tool_call();
689 let commands = match params.query {
690 Some(q) => self.state.registry.search(&q),
691 None => self.state.registry.list(),
692 };
693 json_result(&commands)
694 }
695
696 #[tool(
697 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.",
698 annotations(
699 read_only_hint = true,
700 destructive_hint = false,
701 idempotent_hint = true,
702 open_world_hint = false
703 )
704 )]
705 async fn get_memory_stats(&self) -> CallToolResult {
706 self.track_tool_call();
707 let stats = crate::memory::current_stats();
708 json_result(&stats)
709 }
710
711 #[tool(
712 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.",
713 annotations(
714 read_only_hint = true,
715 destructive_hint = false,
716 idempotent_hint = true,
717 open_world_hint = false
718 )
719 )]
720 async fn get_plugin_info(&self) -> CallToolResult {
721 self.track_tool_call();
722 let disabled: Vec<&str> = self
723 .state
724 .privacy
725 .disabled_tools
726 .iter()
727 .map(std::string::String::as_str)
728 .collect();
729 let blocklist: Vec<&str> = self
730 .state
731 .privacy
732 .command_blocklist
733 .iter()
734 .map(std::string::String::as_str)
735 .collect();
736 let allowlist: Option<Vec<&str>> = self
737 .state
738 .privacy
739 .command_allowlist
740 .as_ref()
741 .map(|s| s.iter().map(std::string::String::as_str).collect());
742 let all_tools = Self::tool_router().list_all();
743 let enabled_tools: Vec<&str> = all_tools
744 .iter()
745 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
746 .map(|t| t.name.as_ref())
747 .collect();
748
749 let app_cfg = self.bridge.tauri_config();
752 let result = serde_json::json!({
753 "version": env!("CARGO_PKG_VERSION"),
754 "bridge_version": BRIDGE_VERSION,
755 "port": self.state.port.load(Ordering::Relaxed),
756 "app": {
757 "identifier": app_cfg.get("identifier"),
758 "product_name": app_cfg.get("product_name"),
759 },
760 "tools": {
761 "total": all_tools.len(),
762 "enabled": enabled_tools.len(),
763 "enabled_list": enabled_tools,
764 "disabled_list": disabled,
765 },
766 "commands": {
767 "allowlist": allowlist,
768 "blocklist": blocklist,
769 },
770 "privacy": {
771 "profile": self.state.privacy.profile.to_string(),
772 "redaction_enabled": self.state.privacy.redaction_enabled,
773 },
774 "capacities": {
775 "event_log": self.state.event_log.capacity(),
776 "eval_timeout_secs": self.state.eval_timeout.as_secs(),
777 },
778 "registered_commands": self.state.registry.count(),
779 "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
780 "uptime_secs": self.state.started_at.elapsed().as_secs(),
781 });
782 json_result(&result)
783 }
784
785 #[tool(
786 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.",
787 annotations(
788 read_only_hint = true,
789 destructive_hint = false,
790 idempotent_hint = true,
791 open_world_hint = false
792 )
793 )]
794 async fn get_diagnostics(
795 &self,
796 Parameters(params): Parameters<DiagnosticsParams>,
797 ) -> CallToolResult {
798 self.eval_bridge(
799 "return window.__VICTAURI__?.getDiagnostics()",
800 params.webview_label.as_deref(),
801 )
802 .await
803 }
804
805 #[tool(
808 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.",
809 annotations(
810 read_only_hint = true,
811 destructive_hint = false,
812 idempotent_hint = true,
813 open_world_hint = false
814 )
815 )]
816 async fn app_info(&self) -> CallToolResult {
817 self.track_tool_call();
818 let config = self.bridge.tauri_config();
819
820 let data_dir = self.bridge.app_data_dir().ok();
821 let config_dir = self.bridge.app_config_dir().ok();
822 let log_dir = self.bridge.app_log_dir().ok();
823 let local_data_dir = self.bridge.app_local_data_dir().ok();
824
825 let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
826 .filter(|(k, _)| is_safe_env_key(k))
827 .collect();
828
829 #[cfg(feature = "sqlite")]
836 let databases: Vec<serde_json::Value> = {
837 let mut all_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
838 for d in [
839 data_dir.as_ref(),
840 config_dir.as_ref(),
841 log_dir.as_ref(),
842 local_data_dir.as_ref(),
843 ]
844 .into_iter()
845 .flatten()
846 {
847 all_dirs.push(d.clone());
848 }
849 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
850 all_dirs.clone()
851 } else {
852 self.state.db_search_paths.clone()
853 };
854 let selected = crate::database::select_app_database(&select_dirs).ok();
855 crate::database::classify_databases(&all_dirs)
856 .into_iter()
857 .map(|c| {
858 serde_json::json!({
859 "path": c.path.to_string_lossy(),
860 "size_bytes": c.size_bytes,
861 "webview_internal": c.webview_internal,
862 "selected": selected.as_ref() == Some(&c.path),
863 })
864 })
865 .collect()
866 };
867
868 #[cfg(not(feature = "sqlite"))]
869 let databases: Vec<serde_json::Value> = Vec::new();
870
871 let result = serde_json::json!({
872 "config": config,
873 "paths": {
874 "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
875 "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
876 "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
877 "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
878 },
879 "databases": databases,
880 "env": env_vars,
881 "process": {
882 "pid": std::process::id(),
883 "arch": std::env::consts::ARCH,
884 "os": std::env::consts::OS,
885 "family": std::env::consts::FAMILY,
886 },
887 });
888 json_result(&result)
889 }
890
891 #[tool(
892 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.",
893 annotations(
894 read_only_hint = true,
895 destructive_hint = false,
896 idempotent_hint = true,
897 open_world_hint = false
898 )
899 )]
900 async fn list_app_dir(
901 &self,
902 Parameters(params): Parameters<ListAppDirParams>,
903 ) -> CallToolResult {
904 self.track_tool_call();
905 let base = match self.resolve_app_dir(params.directory) {
906 Ok(d) => d,
907 Err(e) => return tool_error(e),
908 };
909
910 let target = if let Some(ref sub) = params.path {
911 let resolved = base.join(sub);
912 if !resolved.exists() {
913 return tool_error(format!("directory does not exist: {}", resolved.display()));
914 }
915 if let Err(e) = Self::safe_within(&base, &resolved) {
916 return tool_error(e);
917 }
918 resolved
919 } else {
920 base.clone()
921 };
922
923 if !target.exists() {
924 return tool_error(format!("directory does not exist: {}", target.display()));
925 }
926
927 let max_depth = params.max_depth.unwrap_or(1).min(5);
928 let pattern = params.pattern.as_deref();
929 let mut entries = Vec::new();
930
931 Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
932
933 json_result(&serde_json::json!({
934 "base": base.to_string_lossy(),
935 "path": params.path.unwrap_or_default(),
936 "entries": entries,
937 "count": entries.len(),
938 }))
939 }
940
941 #[tool(
942 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.",
943 annotations(
944 read_only_hint = true,
945 destructive_hint = false,
946 idempotent_hint = true,
947 open_world_hint = false
948 )
949 )]
950 async fn read_app_file(
951 &self,
952 Parameters(params): Parameters<ReadAppFileParams>,
953 ) -> CallToolResult {
954 self.track_tool_call();
955 let base = match self.resolve_app_dir(params.directory) {
956 Ok(d) => d,
957 Err(e) => return tool_error(e),
958 };
959
960 let target = base.join(¶ms.path);
961 if !target.exists() {
962 return tool_error(format!("file not found: {}", params.path));
963 }
964 if let Err(e) = Self::safe_within(&base, &target) {
965 return tool_error(e);
966 }
967 if !target.is_file() {
968 return tool_error(format!("not a file: {}", params.path));
969 }
970
971 let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
972 let metadata = std::fs::metadata(&target).map_err(|e| e.to_string());
973
974 match std::fs::read(&target) {
975 Ok(mut bytes) => {
976 let original_size = bytes.len();
977 let truncated = bytes.len() > max_bytes;
978 if truncated {
979 bytes.truncate(max_bytes);
980 }
981
982 let file_info = serde_json::json!({
983 "path": params.path,
984 "size": original_size,
985 "truncated": truncated,
986 "modified": metadata.as_ref().ok()
987 .and_then(|m| m.modified().ok())
988 .map(|t| {
989 let duration = t.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap_or_default();
990 duration.as_secs()
991 }),
992 });
993
994 if params.binary == Some(true) {
995 use base64::Engine;
996 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
997 json_result(&serde_json::json!({
998 "file": file_info,
999 "encoding": "base64",
1000 "content": b64,
1001 }))
1002 } else {
1003 match String::from_utf8(bytes) {
1004 Ok(text) => json_result(&serde_json::json!({
1005 "file": file_info,
1006 "encoding": "utf-8",
1007 "content": text,
1008 })),
1009 Err(e) => {
1010 use base64::Engine;
1011 let bytes = e.into_bytes();
1012 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1013 json_result(&serde_json::json!({
1014 "file": file_info,
1015 "encoding": "base64",
1016 "note": "file is not valid UTF-8, returning base64",
1017 "content": b64,
1018 }))
1019 }
1020 }
1021 }
1022 }
1023 Err(e) => tool_error(format!("failed to read file: {e}")),
1024 }
1025 }
1026
1027 #[cfg(feature = "sqlite")]
1028 #[tool(
1029 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.",
1030 annotations(
1031 read_only_hint = true,
1032 destructive_hint = false,
1033 idempotent_hint = true,
1034 open_world_hint = false
1035 )
1036 )]
1037 async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1038 self.track_tool_call();
1039 let data_dir = match self.bridge.app_data_dir() {
1040 Ok(d) => d,
1041 Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1042 };
1043
1044 let app_dirs: Vec<std::path::PathBuf> = [
1045 self.bridge.app_data_dir(),
1046 self.bridge.app_config_dir(),
1047 self.bridge.app_local_data_dir(),
1048 self.bridge.app_log_dir(),
1049 ]
1050 .into_iter()
1051 .filter_map(Result::ok)
1052 .collect::<std::collections::HashSet<_>>()
1053 .into_iter()
1054 .collect();
1055 let mut search_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1059 search_dirs.extend(app_dirs);
1060
1061 let db_path = if let Some(ref rel_path) = params.path {
1062 let candidate = std::path::Path::new(rel_path);
1063 if candidate.is_absolute() {
1066 if !candidate.exists() {
1067 return tool_error(format!("database not found: {rel_path}"));
1068 }
1069 if !search_dirs
1070 .iter()
1071 .any(|d| Self::safe_within(d, candidate).is_ok())
1072 {
1073 return tool_error(format!(
1074 "absolute path '{rel_path}' is not within an allowed directory; \
1075 register its parent via VictauriBuilder::db_search_paths"
1076 ));
1077 }
1078 }
1079 let mut found = None;
1080 if candidate.is_absolute() {
1081 found = Some(candidate.to_path_buf());
1082 } else {
1083 for dir in &search_dirs {
1084 let resolved = dir.join(rel_path);
1085 if resolved.exists() {
1086 if let Err(e) = Self::safe_within(dir, &resolved) {
1087 return tool_error(e);
1088 }
1089 found = Some(resolved);
1090 break;
1091 }
1092 }
1093 }
1094 if let Some(p) = found {
1095 p
1096 } else {
1097 let dirs_str = search_dirs
1098 .iter()
1099 .map(|d| d.display().to_string())
1100 .collect::<Vec<_>>()
1101 .join(", ");
1102 return tool_error(format!(
1103 "database not found: {rel_path} (searched: {dirs_str})"
1104 ));
1105 }
1106 } else {
1107 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1114 search_dirs.clone()
1115 } else {
1116 self.state.db_search_paths.clone()
1117 };
1118 match crate::database::select_app_database(&select_dirs) {
1119 Ok(p) => p,
1120 Err(e) => return tool_error(e),
1121 }
1122 };
1123
1124 let db_display = db_path
1125 .strip_prefix(&data_dir)
1126 .unwrap_or(&db_path)
1127 .to_string_lossy()
1128 .into_owned();
1129 let bind_params = params.params.unwrap_or_default();
1130
1131 match crate::database::query(&db_path, ¶ms.query, &bind_params, params.max_rows) {
1132 Ok(mut result) => {
1133 if let Some(obj) = result.as_object_mut() {
1134 obj.insert("database".to_string(), serde_json::json!(db_display));
1135 }
1136 json_result(&result)
1137 }
1138 Err(e) => tool_error(e),
1139 }
1140 }
1141
1142 #[tool(
1145 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.",
1146 annotations(
1147 read_only_hint = false,
1148 destructive_hint = false,
1149 idempotent_hint = false,
1150 open_world_hint = false
1151 )
1152 )]
1153 async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1154 if !self.state.privacy.is_tool_enabled("interact") {
1155 return tool_disabled("interact");
1156 }
1157 match params.action {
1158 InteractAction::Click => {
1159 if !self.state.privacy.is_tool_enabled("interact.click") {
1160 return tool_disabled("interact.click");
1161 }
1162 let Some(ref_id) = ¶ms.ref_id else {
1163 return missing_param("ref_id", "click");
1164 };
1165 if params.trusted.unwrap_or(false) {
1166 let probe = format!(
1169 "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); \
1170 if(!__e) return null; __e.scrollIntoView({{block:'center',inline:'center',behavior:'instant'}}); \
1171 var __b=__e.getBoundingClientRect(); \
1172 return {{x:__b.left+__b.width/2, y:__b.top+__b.height/2}}",
1173 js_string(ref_id)
1174 );
1175 let raw = match self
1176 .eval_with_return(&probe, params.webview_label.as_deref())
1177 .await
1178 {
1179 Ok(r) => r,
1180 Err(e) => return tool_error(e),
1181 };
1182 let Ok(point) = serde_json::from_str::<serde_json::Value>(&raw) else {
1183 return tool_error_with_hint(
1184 format!("ref not found: {ref_id}"),
1185 RecoveryHint::CheckInput,
1186 );
1187 };
1188 let (Some(x), Some(y)) = (
1189 point.get("x").and_then(serde_json::Value::as_f64),
1190 point.get("y").and_then(serde_json::Value::as_f64),
1191 ) else {
1192 return tool_error_with_hint(
1193 format!("ref not found: {ref_id}"),
1194 RecoveryHint::CheckInput,
1195 );
1196 };
1197 return match self
1198 .bridge
1199 .native_click(params.webview_label.as_deref(), x, y)
1200 {
1201 Ok(()) => json_result(
1202 &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1203 ),
1204 Err(e) => tool_error(e),
1205 };
1206 }
1207 let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1208 self.eval_bridge(&code, params.webview_label.as_deref())
1209 .await
1210 }
1211 InteractAction::DoubleClick => {
1212 if !self.state.privacy.is_tool_enabled("interact.double_click") {
1213 return tool_disabled("interact.double_click");
1214 }
1215 let Some(ref_id) = ¶ms.ref_id else {
1216 return missing_param("ref_id", "double_click");
1217 };
1218 let code = format!(
1219 "return window.__VICTAURI__?.doubleClick({})",
1220 js_string(ref_id)
1221 );
1222 self.eval_bridge(&code, params.webview_label.as_deref())
1223 .await
1224 }
1225 InteractAction::Hover => {
1226 if !self.state.privacy.is_tool_enabled("interact.hover") {
1227 return tool_disabled("interact.hover");
1228 }
1229 let Some(ref_id) = ¶ms.ref_id else {
1230 return missing_param("ref_id", "hover");
1231 };
1232 let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1233 self.eval_bridge(&code, params.webview_label.as_deref())
1234 .await
1235 }
1236 InteractAction::Focus => {
1237 if !self.state.privacy.is_tool_enabled("interact.focus") {
1238 return tool_disabled("interact.focus");
1239 }
1240 let Some(ref_id) = ¶ms.ref_id else {
1241 return missing_param("ref_id", "focus");
1242 };
1243 let code = format!(
1244 "return window.__VICTAURI__?.focusElement({})",
1245 js_string(ref_id)
1246 );
1247 self.eval_bridge(&code, params.webview_label.as_deref())
1248 .await
1249 }
1250 InteractAction::ScrollIntoView => {
1251 if !self
1252 .state
1253 .privacy
1254 .is_tool_enabled("interact.scroll_into_view")
1255 {
1256 return tool_disabled("interact.scroll_into_view");
1257 }
1258 let ref_arg = params
1259 .ref_id
1260 .as_ref()
1261 .map_or_else(|| "null".to_string(), |r| js_string(r));
1262 let x = params.x.unwrap_or(0.0);
1263 let y = params.y.unwrap_or(0.0);
1264 let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1265 self.eval_bridge(&code, params.webview_label.as_deref())
1266 .await
1267 }
1268 InteractAction::SelectOption => {
1269 if !self.state.privacy.is_tool_enabled("interact.select_option") {
1270 return tool_disabled("interact.select_option");
1271 }
1272 let Some(ref_id) = ¶ms.ref_id else {
1273 return missing_param("ref_id", "select_option");
1274 };
1275 let values_vec;
1276 let values: &[String] = match (¶ms.values, ¶ms.value) {
1277 (Some(v), _) => v,
1278 (None, Some(v)) => {
1279 values_vec = vec![v.clone()];
1280 &values_vec
1281 }
1282 (None, None) => &[],
1283 };
1284 let values_json =
1285 serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1286 let code = format!(
1287 "return window.__VICTAURI__?.selectOption({}, {})",
1288 js_string(ref_id),
1289 values_json
1290 );
1291 self.eval_bridge(&code, params.webview_label.as_deref())
1292 .await
1293 }
1294 }
1295 }
1296
1297 #[tool(
1298 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.",
1299 annotations(
1300 read_only_hint = false,
1301 destructive_hint = false,
1302 idempotent_hint = false,
1303 open_world_hint = false
1304 )
1305 )]
1306 async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1307 match params.action {
1308 InputAction::Fill => {
1309 if !self.state.privacy.is_tool_enabled("fill") {
1310 return tool_disabled("fill");
1311 }
1312 let Some(ref_id) = ¶ms.ref_id else {
1313 return missing_param("ref_id", "fill");
1314 };
1315 let Some(value) = ¶ms.value else {
1316 return missing_param("value", "fill");
1317 };
1318 let code = format!(
1319 "return window.__VICTAURI__?.fill({}, {})",
1320 js_string(ref_id),
1321 js_string(value)
1322 );
1323 self.eval_bridge(&code, params.webview_label.as_deref())
1324 .await
1325 }
1326 InputAction::TypeText => {
1327 if !self.state.privacy.is_tool_enabled("type_text") {
1328 return tool_disabled("type_text");
1329 }
1330 let Some(ref_id) = ¶ms.ref_id else {
1331 return missing_param("ref_id", "type_text");
1332 };
1333 let Some(text) = ¶ms.text else {
1334 return missing_param("text", "type_text");
1335 };
1336 if params.trusted.unwrap_or(false) {
1337 let focus = format!(
1340 "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1341 js_string(ref_id)
1342 );
1343 let focused = self
1344 .eval_with_return(&focus, params.webview_label.as_deref())
1345 .await
1346 .unwrap_or_default();
1347 if focused != "true" {
1348 return tool_error_with_hint(
1349 format!("ref not found or not focusable: {ref_id}"),
1350 RecoveryHint::CheckInput,
1351 );
1352 }
1353 return match self
1354 .bridge
1355 .native_type_text(params.webview_label.as_deref(), text)
1356 {
1357 Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1358 Err(e) => tool_error(e),
1359 };
1360 }
1361 let code = format!(
1362 "return window.__VICTAURI__?.type({}, {})",
1363 js_string(ref_id),
1364 js_string(text)
1365 );
1366 self.eval_bridge(&code, params.webview_label.as_deref())
1367 .await
1368 }
1369 InputAction::PressKey => {
1370 if !self.state.privacy.is_tool_enabled("input.press_key") {
1371 return tool_disabled("input.press_key");
1372 }
1373 let Some(key) = ¶ms.key else {
1374 return missing_param("key", "press_key");
1375 };
1376 if params.trusted.unwrap_or(false) {
1377 if let Some(ref_id) = ¶ms.ref_id {
1379 let focus = format!(
1380 "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1381 js_string(ref_id)
1382 );
1383 let _ = self
1384 .eval_with_return(&focus, params.webview_label.as_deref())
1385 .await;
1386 }
1387 return match self.bridge.native_key(params.webview_label.as_deref(), key) {
1388 Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1389 Err(e) => tool_error(e),
1390 };
1391 }
1392 let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1393 self.eval_bridge(&code, params.webview_label.as_deref())
1394 .await
1395 }
1396 }
1397 }
1398
1399 #[tool(
1400 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, introspectability (probe every window and report which Victauri can actually see — a visible window that comes back introspectable:false is almost always missing the \"victauri:default\" capability; run this FIRST when eval_js/dom_snapshot/animation return nothing for a multi-window app).",
1401 annotations(
1402 read_only_hint = false,
1403 destructive_hint = false,
1404 idempotent_hint = true,
1405 open_world_hint = false
1406 )
1407 )]
1408 async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1409 self.track_tool_call();
1410 match params.action {
1411 WindowAction::GetState => {
1412 let states = self.bridge.get_window_states(params.label.as_deref());
1413 if states.is_empty()
1416 && let Some(label) = params.label.as_deref()
1417 {
1418 return tool_error(format!(
1419 "window not found: '{label}' (use window.list to see available labels)"
1420 ));
1421 }
1422 json_result(&states)
1423 }
1424 WindowAction::List => {
1425 let labels = self.bridge.list_window_labels();
1426 json_result(&labels)
1427 }
1428 WindowAction::Introspectability => self.window_introspectability().await,
1429 WindowAction::Manage => {
1430 if !self.state.privacy.is_tool_enabled("window.manage") {
1431 return tool_disabled("window.manage");
1432 }
1433 let Some(manage_action) = ¶ms.manage_action else {
1434 return missing_param("manage_action", "manage");
1435 };
1436 match self
1437 .bridge
1438 .manage_window(params.label.as_deref(), manage_action.as_str())
1439 {
1440 Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1441 Err(e) => tool_error(e),
1442 }
1443 }
1444 WindowAction::Resize => {
1445 if !self.state.privacy.is_tool_enabled("window.resize") {
1446 return tool_disabled("window.resize");
1447 }
1448 let Some(width) = params.width else {
1449 return missing_param("width", "resize");
1450 };
1451 let Some(height) = params.height else {
1452 return missing_param("height", "resize");
1453 };
1454 if width == 0 || height == 0 {
1455 return tool_error_with_hint(
1456 format!(
1457 "invalid window size {width}x{height}: width and height must be > 0"
1458 ),
1459 RecoveryHint::CheckInput,
1460 );
1461 }
1462 match self
1463 .bridge
1464 .resize_window(params.label.as_deref(), width, height)
1465 {
1466 Ok(()) => {
1467 let result =
1468 serde_json::json!({"ok": true, "width": width, "height": height});
1469 CallToolResult::success(vec![Content::text(result.to_string())])
1470 }
1471 Err(e) => tool_error(e),
1472 }
1473 }
1474 WindowAction::MoveTo => {
1475 if !self.state.privacy.is_tool_enabled("window.move_to") {
1476 return tool_disabled("window.move_to");
1477 }
1478 let Some(x) = params.x else {
1479 return missing_param("x", "move_to");
1480 };
1481 let Some(y) = params.y else {
1482 return missing_param("y", "move_to");
1483 };
1484 match self.bridge.move_window(params.label.as_deref(), x, y) {
1485 Ok(()) => {
1486 let result = serde_json::json!({"ok": true, "x": x, "y": y});
1487 CallToolResult::success(vec![Content::text(result.to_string())])
1488 }
1489 Err(e) => tool_error(e),
1490 }
1491 }
1492 WindowAction::SetTitle => {
1493 if !self.state.privacy.is_tool_enabled("window.set_title") {
1494 return tool_disabled("window.set_title");
1495 }
1496 let Some(title) = ¶ms.title else {
1497 return missing_param("title", "set_title");
1498 };
1499 match self.bridge.set_window_title(params.label.as_deref(), title) {
1500 Ok(()) => {
1501 let result = serde_json::json!({"ok": true, "title": title});
1502 CallToolResult::success(vec![Content::text(result.to_string())])
1503 }
1504 Err(e) => tool_error(e),
1505 }
1506 }
1507 }
1508 }
1509
1510 #[tool(
1511 description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1512 annotations(
1513 read_only_hint = false,
1514 destructive_hint = true,
1515 idempotent_hint = false,
1516 open_world_hint = false
1517 )
1518 )]
1519 async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1520 match params.action {
1521 StorageAction::Get => {
1522 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1523 StorageType::Session => "getSessionStorage",
1524 StorageType::Local => "getLocalStorage",
1525 };
1526 let key_arg = params
1527 .key
1528 .as_ref()
1529 .map(|k| js_string(k))
1530 .unwrap_or_default();
1531 let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1532 self.eval_bridge(&code, params.webview_label.as_deref())
1533 .await
1534 }
1535 StorageAction::Set => {
1536 if !self.state.privacy.is_tool_enabled("set_storage") {
1537 return tool_disabled("set_storage");
1538 }
1539 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1540 StorageType::Session => "setSessionStorage",
1541 StorageType::Local => "setLocalStorage",
1542 };
1543 let Some(key) = ¶ms.key else {
1544 return missing_param("key", "set");
1545 };
1546 if !self.state.privacy.is_storage_key_allowed(key) {
1549 return tool_error(format!(
1550 "storage key '{key}' is protected by privacy configuration"
1551 ));
1552 }
1553 let value = params
1554 .value
1555 .as_ref()
1556 .cloned()
1557 .unwrap_or(serde_json::Value::Null);
1558 let value_json =
1559 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1560 let code = format!(
1561 "return window.__VICTAURI__?.{method}({}, {value_json})",
1562 js_string(key)
1563 );
1564 self.eval_bridge(&code, params.webview_label.as_deref())
1565 .await
1566 }
1567 StorageAction::Delete => {
1568 if !self.state.privacy.is_tool_enabled("delete_storage") {
1569 return tool_disabled("delete_storage");
1570 }
1571 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1572 StorageType::Session => "deleteSessionStorage",
1573 StorageType::Local => "deleteLocalStorage",
1574 };
1575 let Some(key) = ¶ms.key else {
1576 return missing_param("key", "delete");
1577 };
1578 let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1579 self.eval_bridge(&code, params.webview_label.as_deref())
1580 .await
1581 }
1582 StorageAction::GetCookies => {
1583 self.eval_bridge(
1584 "return window.__VICTAURI__?.getCookies()",
1585 params.webview_label.as_deref(),
1586 )
1587 .await
1588 }
1589 }
1590 }
1591
1592 #[tool(
1593 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.",
1594 annotations(
1595 read_only_hint = false,
1596 destructive_hint = false,
1597 idempotent_hint = false,
1598 open_world_hint = false
1599 )
1600 )]
1601 async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1602 match params.action {
1603 NavigateAction::GoTo => {
1604 if !self.state.privacy.is_tool_enabled("navigate") {
1605 return tool_disabled("navigate");
1606 }
1607 let Some(url) = ¶ms.url else {
1608 return missing_param("url", "go_to");
1609 };
1610 if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1611 return tool_error(e);
1612 }
1613 let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1614 self.eval_bridge(&code, params.webview_label.as_deref())
1615 .await
1616 }
1617 NavigateAction::GoBack => {
1618 self.eval_bridge(
1619 "return window.__VICTAURI__?.navigateBack()",
1620 params.webview_label.as_deref(),
1621 )
1622 .await
1623 }
1624 NavigateAction::GetHistory => {
1625 self.eval_bridge(
1626 "return window.__VICTAURI__?.getNavigationLog()",
1627 params.webview_label.as_deref(),
1628 )
1629 .await
1630 }
1631 NavigateAction::SetDialogResponse => {
1632 if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1633 return tool_disabled("set_dialog_response");
1634 }
1635 let Some(dialog_type) = params.dialog_type else {
1636 return missing_param("dialog_type", "set_dialog_response");
1637 };
1638 let Some(dialog_action) = params.dialog_action else {
1639 return missing_param("dialog_action", "set_dialog_response");
1640 };
1641 let text_arg = params
1642 .text
1643 .as_ref()
1644 .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1645 let code = format!(
1646 "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1647 js_string(dialog_type.as_str()),
1648 js_string(dialog_action.as_str())
1649 );
1650 self.eval_bridge(&code, params.webview_label.as_deref())
1651 .await
1652 }
1653 NavigateAction::GetDialogLog => {
1654 self.eval_bridge(
1655 "return window.__VICTAURI__?.getDialogLog()",
1656 params.webview_label.as_deref(),
1657 )
1658 .await
1659 }
1660 }
1661 }
1662
1663 #[tool(
1664 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), flush (immediately drain pending events into recording without waiting for the 1-second poll).",
1665 annotations(
1666 read_only_hint = false,
1667 destructive_hint = false,
1668 idempotent_hint = false,
1669 open_world_hint = false
1670 )
1671 )]
1672 async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1673 const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1674 self.track_tool_call();
1675 if !self.state.privacy.is_tool_enabled("recording") {
1676 return tool_disabled("recording");
1677 }
1678 match params.action {
1679 RecordingAction::Start => {
1680 let session_id = params
1681 .session_id
1682 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1683 match self.state.recorder.start(session_id.clone()) {
1684 Ok(()) => {
1685 let result = serde_json::json!({
1686 "started": true,
1687 "session_id": session_id,
1688 });
1689 CallToolResult::success(vec![Content::text(result.to_string())])
1690 }
1691 Err(e) => tool_error(e.to_string()),
1692 }
1693 }
1694 RecordingAction::Stop => match self.state.recorder.stop() {
1695 Some(session) => json_result(&session),
1696 None => tool_error("no recording is active"),
1697 },
1698 RecordingAction::Checkpoint => {
1699 let Some(id) = params.checkpoint_id else {
1700 return missing_param("checkpoint_id", "checkpoint");
1701 };
1702 let state = params.state.unwrap_or(serde_json::Value::Null);
1703 match self
1704 .state
1705 .recorder
1706 .checkpoint(id.clone(), params.checkpoint_label, state)
1707 {
1708 Ok(()) => {
1709 let result = serde_json::json!({
1710 "created": true,
1711 "checkpoint_id": id,
1712 "event_index": self.state.recorder.event_count(),
1713 });
1714 CallToolResult::success(vec![Content::text(result.to_string())])
1715 }
1716 Err(e) => tool_error(e.to_string()),
1717 }
1718 }
1719 RecordingAction::ListCheckpoints => {
1720 let checkpoints = self.state.recorder.get_checkpoints();
1721 json_result(&checkpoints)
1722 }
1723 RecordingAction::GetEvents => {
1724 let events = self
1725 .state
1726 .recorder
1727 .events_since(params.since_index.unwrap_or(0));
1728 json_result(&events)
1729 }
1730 RecordingAction::EventsBetween => {
1731 let Some(from) = ¶ms.from else {
1732 return missing_param("from", "events_between");
1733 };
1734 let Some(to) = ¶ms.to else {
1735 return missing_param("to", "events_between");
1736 };
1737 match self.state.recorder.events_between_checkpoints(from, to) {
1738 Ok(events) => json_result(&events),
1739 Err(e) => tool_error(e.to_string()),
1740 }
1741 }
1742 RecordingAction::GetReplay => {
1743 let calls = self.state.recorder.ipc_replay_sequence();
1744 json_result(&calls)
1745 }
1746 RecordingAction::Export => match self.state.recorder.export() {
1747 Some(s) => {
1748 let json = serde_json::to_string_pretty(&s)
1749 .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1750 CallToolResult::success(vec![Content::text(json)])
1751 }
1752 None => tool_error("no recording is active — start one first"),
1753 },
1754 RecordingAction::Import => {
1755 let Some(session_json) = ¶ms.session_json else {
1756 return missing_param("session_json", "import");
1757 };
1758 if session_json.len() > MAX_SESSION_JSON {
1759 return tool_error("session JSON exceeds maximum size (10 MB)");
1760 }
1761 let session: victauri_core::RecordedSession =
1762 match serde_json::from_str(session_json) {
1763 Ok(s) => s,
1764 Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1765 };
1766
1767 let result = serde_json::json!({
1768 "imported": true,
1769 "session_id": session.id,
1770 "event_count": session.events.len(),
1771 "checkpoint_count": session.checkpoints.len(),
1772 "started_at": session.started_at.to_rfc3339(),
1773 });
1774 self.state.recorder.import(session);
1775 CallToolResult::success(vec![Content::text(result.to_string())])
1776 }
1777 RecordingAction::Flush => {
1778 if !self.state.recorder.is_recording() {
1779 return tool_error("no active recording — start a recording first");
1780 }
1781 let code = "return window.__VICTAURI__?.getEventStream(0)";
1782 match self
1783 .eval_with_return(code, params.webview_label.as_deref())
1784 .await
1785 {
1786 Ok(result_str) => {
1787 let events: Vec<serde_json::Value> =
1788 serde_json::from_str(&result_str).unwrap_or_default();
1789 let mut count = 0u64;
1790 for ev in &events {
1791 if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
1792 self.state.event_log.push(app_event.clone());
1793 self.state.recorder.record_event(app_event);
1794 count += 1;
1795 }
1796 }
1797 json_result(&serde_json::json!({
1798 "flushed": true,
1799 "events_captured": count,
1800 }))
1801 }
1802 Err(e) => tool_error(format!("flush failed: {e}")),
1803 }
1804 }
1805 RecordingAction::Replay => {
1806 let calls = self.state.recorder.ipc_replay_sequence();
1807 if calls.is_empty() {
1808 return tool_error("no IPC calls recorded — record a session first");
1809 }
1810 let mut replay_results = Vec::new();
1811 for call in &calls {
1812 if !self.state.privacy.is_invoke_allowed(&call.command)
1816 || !self.state.privacy.is_command_allowed(&call.command)
1817 {
1818 replay_results.push(serde_json::json!({
1819 "command": call.command,
1820 "status": "blocked",
1821 "error": "blocked by privacy configuration",
1822 }));
1823 continue;
1824 }
1825 let code = format!(
1826 "return window.__TAURI_INTERNALS__.invoke({})",
1827 js_string(&call.command)
1828 );
1829 let outcome = match self
1830 .eval_with_return(&code, params.webview_label.as_deref())
1831 .await
1832 {
1833 Ok(result_str) => {
1834 let value: serde_json::Value = serde_json::from_str(&result_str)
1835 .unwrap_or(serde_json::Value::String(result_str));
1836 let shape = crate::introspection::JsonShape::from_value(&value);
1837 serde_json::json!({
1838 "command": call.command,
1839 "status": "ok",
1840 "response_type": shape.type_name(),
1841 })
1842 }
1843 Err(e) => {
1844 serde_json::json!({
1845 "command": call.command,
1846 "status": "error",
1847 "error": e,
1848 })
1849 }
1850 };
1851 replay_results.push(outcome);
1852 }
1853 let passed = replay_results
1854 .iter()
1855 .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
1856 .count();
1857 let result = serde_json::json!({
1858 "replayed": replay_results.len(),
1859 "passed": passed,
1860 "failed": replay_results.len() - passed,
1861 "results": replay_results,
1862 });
1863 json_result(&result)
1864 }
1865 }
1866 }
1867
1868 #[tool(
1869 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).",
1870 annotations(
1871 read_only_hint = true,
1872 destructive_hint = false,
1873 idempotent_hint = true,
1874 open_world_hint = false
1875 )
1876 )]
1877 async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1878 match params.action {
1879 InspectAction::GetStyles => {
1880 let Some(ref_id) = ¶ms.ref_id else {
1881 return missing_param("ref_id", "get_styles");
1882 };
1883 let props_arg = match ¶ms.properties {
1884 Some(props) => {
1885 let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1886 format!("[{}]", arr.join(","))
1887 }
1888 None => "null".to_string(),
1889 };
1890 let code = format!(
1891 "return window.__VICTAURI__?.getStyles({}, {})",
1892 js_string(ref_id),
1893 props_arg
1894 );
1895 self.eval_bridge(&code, params.webview_label.as_deref())
1896 .await
1897 }
1898 InspectAction::GetBoundingBoxes => {
1899 let Some(ref_ids) = ¶ms.ref_ids else {
1900 return missing_param("ref_ids", "get_bounding_boxes");
1901 };
1902 let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1903 let code = format!(
1904 "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1905 refs.join(",")
1906 );
1907 self.eval_bridge(&code, params.webview_label.as_deref())
1908 .await
1909 }
1910 InspectAction::Highlight => {
1911 let Some(ref_id) = ¶ms.ref_id else {
1912 return missing_param("ref_id", "highlight");
1913 };
1914 let color_arg = match ¶ms.color {
1915 Some(c) => match sanitize_css_color(c) {
1916 Ok(safe) => format!("\"{safe}\""),
1917 Err(e) => return tool_error(e),
1918 },
1919 None => "null".to_string(),
1920 };
1921 let label_arg = match ¶ms.label {
1922 Some(l) => js_string(l),
1923 None => "null".to_string(),
1924 };
1925 let code = format!(
1926 "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1927 js_string(ref_id),
1928 color_arg,
1929 label_arg
1930 );
1931 self.eval_bridge(&code, params.webview_label.as_deref())
1932 .await
1933 }
1934 InspectAction::ClearHighlights => {
1935 self.eval_bridge(
1936 "return window.__VICTAURI__?.clearHighlights()",
1937 params.webview_label.as_deref(),
1938 )
1939 .await
1940 }
1941 InspectAction::AuditAccessibility => {
1942 self.eval_bridge(
1943 "return window.__VICTAURI__?.auditAccessibility()",
1944 params.webview_label.as_deref(),
1945 )
1946 .await
1947 }
1948 InspectAction::GetPerformance => {
1949 self.eval_bridge(
1950 "return window.__VICTAURI__?.getPerformanceMetrics()",
1951 params.webview_label.as_deref(),
1952 )
1953 .await
1954 }
1955 }
1956 }
1957
1958 #[tool(
1959 description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1960 annotations(
1961 read_only_hint = false,
1962 destructive_hint = false,
1963 idempotent_hint = true,
1964 open_world_hint = false
1965 )
1966 )]
1967 async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1968 match params.action {
1969 CssAction::Inject => {
1970 if !self.state.privacy.is_tool_enabled("inject_css") {
1971 return tool_disabled("inject_css");
1972 }
1973 let Some(css) = ¶ms.css else {
1974 return missing_param("css", "inject");
1975 };
1976 if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
1978 return tool_error(e);
1979 }
1980 let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1981 self.eval_bridge(&code, params.webview_label.as_deref())
1982 .await
1983 }
1984 CssAction::Remove => {
1985 if !self.state.privacy.is_tool_enabled("css.remove") {
1986 return tool_disabled("css.remove");
1987 }
1988 self.eval_bridge(
1989 "return window.__VICTAURI__?.removeInjectedCss()",
1990 params.webview_label.as_deref(),
1991 )
1992 .await
1993 }
1994 }
1995 }
1996
1997 #[tool(
1998 description = "Network request interception (Playwright route() equivalent, no CDP). \
1999 Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
2000 Actions:\n\
2001 - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
2002 and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
2003 mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
2004 `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
2005 - `list`: list active rules.\n\
2006 - `clear` (by `id`) / `clear_all`: remove rules.\n\
2007 - `matches`: log of intercepted requests.\n\
2008 Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2009 Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2010 Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/\
2011 fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so \
2012 it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the \
2013 `fault` tool only affects commands you drive via `invoke_command`, not real user IPC.",
2014 annotations(
2015 read_only_hint = false,
2016 destructive_hint = false,
2017 idempotent_hint = false,
2018 open_world_hint = false
2019 )
2020 )]
2021 async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2022 self.track_tool_call();
2023 match params.action {
2024 RouteAction::Add => {
2025 if !self.state.privacy.is_tool_enabled("route.add") {
2026 return tool_disabled("route.add");
2027 }
2028 let Some(pattern) = ¶ms.pattern else {
2029 return missing_param("pattern", "add");
2030 };
2031 let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2032 let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2033 let mut rule = serde_json::json!({
2034 "pattern": pattern,
2035 "match_type": match_type.as_str(),
2036 "action": behavior.as_str(),
2037 });
2038 if let Some(m) = ¶ms.method {
2039 rule["method"] = serde_json::json!(m);
2040 }
2041 if let Some(s) = params.status {
2042 rule["status"] = serde_json::json!(s);
2043 }
2044 if let Some(st) = ¶ms.status_text {
2045 rule["status_text"] = serde_json::json!(st);
2046 }
2047 if let Some(h) = ¶ms.headers {
2048 rule["headers"] = h.clone();
2049 }
2050 if let Some(b) = ¶ms.body {
2051 rule["body"] = match b {
2054 serde_json::Value::String(s) => serde_json::json!(s),
2055 other => serde_json::json!(other.to_string()),
2056 };
2057 }
2058 if let Some(ct) = ¶ms.content_type {
2059 rule["content_type"] = serde_json::json!(ct);
2060 }
2061 if let Some(d) = params.delay_ms {
2062 rule["delay_ms"] = serde_json::json!(d);
2063 }
2064 if let Some(t) = params.times {
2065 rule["times"] = serde_json::json!(t);
2066 }
2067 let code = format!(
2068 "return window.__VICTAURI__?.addRoute({})",
2069 js_string(&rule.to_string())
2070 );
2071 self.eval_bridge(&code, params.webview_label.as_deref())
2072 .await
2073 }
2074 RouteAction::List => {
2075 self.eval_bridge(
2076 "return window.__VICTAURI__?.getRouteRules()",
2077 params.webview_label.as_deref(),
2078 )
2079 .await
2080 }
2081 RouteAction::Clear => {
2082 let Some(id) = params.id else {
2083 return missing_param("id", "clear");
2084 };
2085 let code = format!("return window.__VICTAURI__?.clearRoute({id})");
2086 self.eval_bridge(&code, params.webview_label.as_deref())
2087 .await
2088 }
2089 RouteAction::ClearAll => {
2090 self.eval_bridge(
2091 "return window.__VICTAURI__?.clearRoutes()",
2092 params.webview_label.as_deref(),
2093 )
2094 .await
2095 }
2096 RouteAction::Matches => {
2097 let limit = params.limit.unwrap_or(100);
2098 let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2099 self.eval_bridge(&code, params.webview_label.as_deref())
2100 .await
2101 }
2102 }
2103 }
2104
2105 #[tool(
2106 description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2107 into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2108 `logs` (network/console). Actions:\n\
2109 - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2110 `with_events=true` to also start the event recorder.\n\
2111 - `stop`: stop and return a summary (frame count, duration, timestamps).\n\
2112 - `status`: active flag + buffered frame count.\n\
2113 - `frames`: return captured frames as base64 PNGs (`limit` caps how many).",
2114 annotations(
2115 read_only_hint = false,
2116 destructive_hint = false,
2117 idempotent_hint = false,
2118 open_world_hint = false
2119 )
2120 )]
2121 async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2122 self.track_tool_call();
2123 if !self.state.privacy.is_tool_enabled("trace")
2124 || !self.state.privacy.is_tool_enabled("screenshot")
2125 {
2126 return tool_disabled("trace");
2127 }
2128 match params.action {
2129 TraceAction::Start => {
2130 let interval = params.interval_ms.unwrap_or(500);
2131 let max_frames = params.max_frames.unwrap_or(60);
2132 let label = params.webview_label.clone();
2133 let generation = self
2134 .state
2135 .screencast
2136 .start(interval, max_frames, label.clone());
2137
2138 let mut events_started = false;
2139 if params.with_events.unwrap_or(false) {
2140 let session_id = uuid::Uuid::new_v4().to_string();
2141 if self.state.recorder.start(session_id).is_ok() {
2142 events_started = true;
2143 }
2144 }
2145
2146 let bridge = self.bridge.clone();
2149 let screencast = self.state.screencast.clone();
2150 tokio::spawn(async move {
2151 let t0 = std::time::Instant::now();
2152 while screencast.is_active() && screencast.generation() == generation {
2153 if let Ok(handle) = bridge.get_native_handle(label.as_deref())
2154 && let Ok(png) = crate::screenshot::capture_window(handle).await
2155 {
2156 use base64::Engine;
2157 let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2158 #[allow(clippy::cast_possible_truncation)]
2159 screencast.push_frame(t0.elapsed().as_millis() as u64, b64);
2160 }
2161 tokio::time::sleep(std::time::Duration::from_millis(
2162 screencast.interval_ms(),
2163 ))
2164 .await;
2165 }
2166 });
2167
2168 json_result(&serde_json::json!({
2169 "started": true,
2170 "interval_ms": interval.max(50),
2171 "max_frames": max_frames.clamp(1, 600),
2172 "with_events": events_started,
2173 }))
2174 }
2175 TraceAction::Stop => {
2176 let frame_count = self.state.screencast.stop();
2177 let timestamps = self.state.screencast.frame_timestamps();
2178 let duration_ms = timestamps.last().copied().unwrap_or(0);
2179 let event_count = self.state.recorder.event_count();
2180 json_result(&serde_json::json!({
2181 "stopped": true,
2182 "frame_count": frame_count,
2183 "duration_ms": duration_ms,
2184 "frame_timestamps_ms": timestamps,
2185 "recorded_event_count": event_count,
2186 "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2187 }))
2188 }
2189 TraceAction::Status => json_result(&serde_json::json!({
2190 "active": self.state.screencast.is_active(),
2191 "frame_count": self.state.screencast.frame_count(),
2192 "interval_ms": self.state.screencast.interval_ms(),
2193 })),
2194 TraceAction::Frames => {
2195 let limit = params.limit.unwrap_or(0);
2196 let frames = self.state.screencast.frames(limit);
2197 let items: Vec<Content> = frames
2198 .into_iter()
2199 .map(|f| Content::image(f.data_b64, "image/png"))
2200 .collect();
2201 if items.is_empty() {
2202 return json_result(&serde_json::json!({ "frames": 0 }));
2203 }
2204 CallToolResult::success(items)
2205 }
2206 }
2207 }
2208
2209 #[tool(
2210 description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
2211 the webview's animation engine is actually running — duration, delay, easing, iterations, \
2212 keyframes, current progress, and the animating element. Standard DOM, so it works \
2213 identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
2214 - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
2215 each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
2216 - `scrub`: deterministically pause the target's animation and seek it to `points` \
2217 evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
2218 + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
2219 PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
2220 Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
2221 animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
2222 - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
2223 on `selector` (or the first animating element); then trigger the animation; then call \
2224 with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
2225 max frame gap) and declared-vs-measured duration. Works for ANY animation including \
2226 JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
2227 NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
2228 notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
2229 annotations(
2230 read_only_hint = true,
2231 destructive_hint = false,
2232 idempotent_hint = true,
2233 open_world_hint = false
2234 )
2235 )]
2236 async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
2237 self.track_tool_call();
2238 if !self.state.privacy.is_tool_enabled("animation") {
2239 return tool_disabled("animation");
2240 }
2241 match params.action {
2242 AnimationAction::List => {
2243 let sel = params
2244 .selector
2245 .as_deref()
2246 .map_or_else(|| "null".to_string(), js_string);
2247 let code = format!(
2248 "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
2249 );
2250 match self
2251 .eval_with_return(&code, params.webview_label.as_deref())
2252 .await
2253 {
2254 Ok(result_str) => {
2255 match serde_json::from_str::<serde_json::Value>(&result_str) {
2256 Ok(v) => json_result(&v),
2257 Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2258 }
2259 }
2260 Err(e) => tool_error(format!("animation list failed: {e}")),
2261 }
2262 }
2263 AnimationAction::Scrub => self.animation_scrub(params).await,
2264 AnimationAction::Sample => {
2265 let label = params.webview_label.as_deref();
2266 let sel = params
2267 .selector
2268 .as_deref()
2269 .map_or_else(|| "null".to_string(), js_string);
2270 let code = if params.record.unwrap_or(false) {
2271 format!("return window.__VICTAURI__.installSweepRecorder({sel})")
2272 } else {
2273 let clear = params.clear.unwrap_or(false);
2274 format!("return window.__VICTAURI__.readSweep({clear})")
2275 };
2276 match self.eval_with_return(&code, label).await {
2277 Ok(result_str) => {
2278 match serde_json::from_str::<serde_json::Value>(&result_str) {
2279 Ok(v) => json_result(&v),
2280 Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2281 }
2282 }
2283 Err(e) => tool_error(format!("animation sample failed: {e}")),
2284 }
2285 }
2286 }
2287 }
2288
2289 async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
2292 let label = params.webview_label.as_deref();
2293 let sel = params
2294 .selector
2295 .as_deref()
2296 .map_or_else(|| "null".to_string(), js_string);
2297
2298 let prep_code = format!("return await window.__VICTAURI__.scrubPrepare({sel})");
2300 let prep_v = match self.eval_with_return(&prep_code, label).await {
2301 Ok(s) => {
2302 serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
2303 }
2304 Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
2305 };
2306 if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
2307 return json_result(&prep_v);
2309 }
2310
2311 let points = params.points.unwrap_or(20).clamp(2, 120);
2312 let capture = params.capture.unwrap_or(false);
2313 let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
2314 let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
2315 let mut manifest: Vec<serde_json::Value> = Vec::new();
2316
2317 for i in 0..points {
2319 #[allow(clippy::cast_precision_loss)]
2320 let progress = i as f64 / (points - 1) as f64;
2321 let seek_code = format!("return await window.__VICTAURI__.scrubSeek({progress})");
2322 match self.eval_with_return(&seek_code, label).await {
2323 Ok(s) => {
2324 let v = serde_json::from_str::<serde_json::Value>(&s)
2325 .unwrap_or(serde_json::Value::Null);
2326 if capture
2327 && let Ok(handle) = self.bridge.get_native_handle(label)
2328 && let Ok((rgba, w, h)) =
2329 crate::screenshot::capture_window_raw(handle).await
2330 && let Some(frame) = crate::filmstrip::Frame::new(rgba, w, h)
2331 {
2332 manifest.push(serde_json::json!({
2333 "cell": frames.len(),
2334 "progress": progress,
2335 "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
2336 }));
2337 frames.push(frame);
2338 }
2339 curve.push(v);
2340 }
2341 Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
2342 }
2343 }
2344
2345 let resume = params.restore.unwrap_or(true);
2347 let restore_code = format!("return window.__VICTAURI__.scrubRestore({resume})");
2348 let _ = self.eval_with_return(&restore_code, label).await;
2349
2350 let mut meta = serde_json::json!({
2351 "scrubbed": true,
2352 "points": points,
2353 "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
2354 "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
2355 "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
2356 "captured": capture,
2357 "curve": curve,
2358 });
2359
2360 if capture && !frames.is_empty() {
2362 let cols = params
2363 .cols
2364 .unwrap_or_else(|| crate::filmstrip::default_cols(frames.len()));
2365 if let Some((rgba, w, h)) =
2366 crate::filmstrip::compose(&frames, cols, 4, [20, 20, 20, 255])
2367 {
2368 match crate::screenshot::encode_png(w, h, &rgba) {
2369 Ok(png) => {
2370 use base64::Engine;
2371 let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2372 meta["filmstrip"] = serde_json::json!({
2373 "cols": cols,
2374 "frame_count": frames.len(),
2375 "width": w,
2376 "height": h,
2377 "manifest": manifest,
2378 });
2379 return CallToolResult::success(vec![
2380 Content::image(b64, "image/png"),
2381 Content::text(meta.to_string()),
2382 ]);
2383 }
2384 Err(e) => return tool_error(format!("filmstrip encode failed: {e}")),
2385 }
2386 }
2387 }
2388
2389 json_result(&meta)
2390 }
2391
2392 #[tool(
2393 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).",
2394 annotations(
2395 read_only_hint = true,
2396 destructive_hint = false,
2397 idempotent_hint = true,
2398 open_world_hint = false
2399 )
2400 )]
2401 async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
2402 match params.action {
2403 LogsAction::Console => {
2404 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2405 let base = if since_arg.is_empty() {
2406 "window.__VICTAURI__?.getConsoleLogs()".to_string()
2407 } else {
2408 format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
2409 };
2410 let code = if let Some(limit) = params.limit {
2411 format!("return ({base} || []).slice(-{limit})")
2412 } else {
2413 format!("return {base}")
2414 };
2415 self.eval_bridge(&code, params.webview_label.as_deref())
2416 .await
2417 }
2418 LogsAction::Network => {
2419 let filter_arg = params
2420 .filter
2421 .as_ref()
2422 .map_or_else(|| "null".to_string(), |f| js_string(f));
2423 let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2424 let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
2425 let code = trimmed_log_js(&source, limit);
2426 self.eval_bridge(&code, params.webview_label.as_deref())
2427 .await
2428 }
2429 LogsAction::Ipc => {
2430 let wait = params.wait_for_capture.unwrap_or(false);
2431 let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2432 if wait {
2433 let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit);
2434 let code = format!(
2435 r"return (async function() {{
2436 await window.__VICTAURI__.waitForIpcComplete(500);
2437 return (function() {{ {inner} }})();
2438 }})()"
2439 );
2440 let timeout = std::time::Duration::from_millis(5000);
2441 match self
2442 .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
2443 .await
2444 {
2445 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2446 Err(e) => tool_error(e),
2447 }
2448 } else {
2449 let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit);
2450 self.eval_bridge(&code, params.webview_label.as_deref())
2451 .await
2452 }
2453 }
2454 LogsAction::Navigation => {
2455 let code = if let Some(limit) = params.limit {
2456 format!(
2457 "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
2458 )
2459 } else {
2460 "return window.__VICTAURI__?.getNavigationLog()".to_string()
2461 };
2462 self.eval_bridge(&code, params.webview_label.as_deref())
2463 .await
2464 }
2465 LogsAction::Dialogs => {
2466 let code = if let Some(limit) = params.limit {
2467 format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
2468 } else {
2469 "return window.__VICTAURI__?.getDialogLog()".to_string()
2470 };
2471 self.eval_bridge(&code, params.webview_label.as_deref())
2472 .await
2473 }
2474 LogsAction::Events => {
2475 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2476 let base = if since_arg.is_empty() {
2477 "window.__VICTAURI__?.getEventStream()".to_string()
2478 } else {
2479 format!("window.__VICTAURI__?.getEventStream({since_arg})")
2480 };
2481 let code = if let Some(limit) = params.limit {
2482 format!("return ({base} || []).slice(-{limit})")
2483 } else {
2484 format!("return {base}")
2485 };
2486 self.eval_bridge(&code, params.webview_label.as_deref())
2487 .await
2488 }
2489 LogsAction::SlowIpc => {
2490 let Some(threshold) = params.threshold_ms else {
2491 return missing_param("threshold_ms", "slow_ipc");
2492 };
2493 let limit = params.limit.unwrap_or(20);
2494 let mb = MAX_LOG_FIELD_BYTES;
2495 let code = format!(
2496 r"return (function() {{
2497 var MB = {mb};
2498 function trimField(v) {{
2499 if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
2500 if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }}
2501 return v;
2502 }}
2503 function trimEntry(e) {{ if (e == null || typeof e !== 'object') return e; var o = {{}}; for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) o[k] = trimField(e[k]); }} return o; }}
2504 var log = window.__VICTAURI__?.getIpcLog() || [];
2505 var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
2506 slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
2507 return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }};
2508 }})()",
2509 );
2510 self.eval_bridge(&code, None).await
2511 }
2512 LogsAction::Clear => {
2513 let code = "return (function(){ var b = window.__VICTAURI__; if (!b) return { ok:false, error:'bridge unavailable' }; if (b.clearIpcLog) b.clearIpcLog(); if (b.clearNetworkLog) b.clearNetworkLog(); return { ok:true, cleared:['ipc','network'] }; })()";
2514 self.eval_bridge(code, params.webview_label.as_deref())
2515 .await
2516 }
2517 }
2518 }
2519
2520 #[tool(
2523 description = "Deep backend introspection — command profiling, IPC contract testing, \
2524 coverage, startup timing, capability auditing, database diagnostics, process \
2525 enumeration, and event bus monitoring. \
2526 These features exploit Victauri's position inside the Rust process.\n\n\
2527 Actions:\n\
2528 - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
2529 - `coverage`: Which registered commands have been called during this session.\n\
2530 - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
2531 - `contract_check`: Check all recorded contracts for schema drift.\n\
2532 - `contract_list`: List all recorded contract baselines.\n\
2533 - `contract_clear`: Clear all recorded contract baselines.\n\
2534 - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
2535 - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
2536 - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
2537 - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
2538 - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
2539 - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
2540 - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
2541 - `event_bus_clear`: Clear the event bus capture buffer.",
2542 annotations(
2543 read_only_hint = true,
2544 destructive_hint = false,
2545 idempotent_hint = true,
2546 open_world_hint = false
2547 )
2548 )]
2549 async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
2550 self.track_tool_call();
2551 if !self.state.privacy.is_tool_enabled("introspect") {
2552 return tool_disabled("introspect");
2553 }
2554
2555 match params.action {
2556 IntrospectAction::CommandTimings => {
2557 let mut stats = self.state.command_timings.all_stats();
2558 if let Some(threshold) = params.slow_threshold_ms {
2559 stats.retain(|s| s.avg_ms >= threshold);
2560 }
2561 let result = serde_json::json!({
2562 "commands": stats,
2563 "total_commands_profiled": self.state.command_timings.all_stats().len(),
2564 "slow_threshold_ms": params.slow_threshold_ms,
2565 });
2566 json_result(&result)
2567 }
2568 IntrospectAction::Coverage => {
2569 let registered: Vec<String> = self
2570 .state
2571 .registry
2572 .list()
2573 .iter()
2574 .map(|c| c.name.clone())
2575 .collect();
2576
2577 let code = "return window.__VICTAURI__?.getIpcLog()";
2578 let invoked: std::collections::HashSet<String> = match self
2579 .eval_with_return(code, params.webview_label.as_deref())
2580 .await
2581 {
2582 Ok(json_str) => {
2583 if let Ok(entries) =
2584 serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2585 {
2586 entries
2587 .iter()
2588 .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
2589 .map(String::from)
2590 .collect()
2591 } else {
2592 std::collections::HashSet::new()
2593 }
2594 }
2595 Err(_) => std::collections::HashSet::new(),
2596 };
2597
2598 let uncovered: Vec<&String> = registered
2599 .iter()
2600 .filter(|cmd| !invoked.contains(cmd.as_str()))
2601 .collect();
2602
2603 let coverage_pct = if registered.is_empty() {
2604 100.0
2605 } else {
2606 let covered = registered.len() - uncovered.len();
2607 (covered as f64 / registered.len() as f64) * 100.0
2608 };
2609
2610 let result = serde_json::json!({
2611 "registered_commands": registered.len(),
2612 "invoked_commands": invoked.len(),
2613 "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2614 "uncovered": uncovered,
2615 "invoked_not_registered": invoked.iter()
2616 .filter(|cmd| !registered.contains(cmd))
2617 .collect::<Vec<_>>(),
2618 });
2619 json_result(&result)
2620 }
2621 IntrospectAction::ContractRecord => {
2622 let Some(command) = params.command else {
2623 return missing_param("command", "contract_record");
2624 };
2625 if !self.state.privacy.is_invoke_allowed(&command)
2628 || !self.state.privacy.is_command_allowed(&command)
2629 {
2630 return tool_error(format!(
2631 "command '{command}' is blocked by privacy configuration"
2632 ));
2633 }
2634 let args_json = params.args.unwrap_or(serde_json::json!({}));
2635 let args_str =
2636 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
2637 let code = format!(
2638 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2639 js_string(&command)
2640 );
2641 match self
2642 .eval_with_return(&code, params.webview_label.as_deref())
2643 .await
2644 {
2645 Ok(result_str) => {
2646 let value: serde_json::Value = serde_json::from_str(&result_str)
2647 .unwrap_or(serde_json::Value::String(result_str.clone()));
2648 let shape = crate::introspection::JsonShape::from_value(&value);
2649 let sample = if result_str.len() > 4096 {
2650 format!("{}...(truncated)", &result_str[..4096])
2651 } else {
2652 result_str
2653 };
2654 let baseline = crate::introspection::ContractBaseline {
2655 command: command.clone(),
2656 args: args_json,
2657 shape: shape.clone(),
2658 sample,
2659 recorded_at: chrono_now(),
2660 };
2661 self.state.contract_store.record(baseline);
2662 let result = serde_json::json!({
2663 "recorded": true,
2664 "command": command,
2665 "shape_type": shape.type_name(),
2666 });
2667 json_result(&result)
2668 }
2669 Err(e) => tool_error(format!(
2670 "failed to invoke '{command}' for contract recording: {e}"
2671 )),
2672 }
2673 }
2674 IntrospectAction::ContractCheck => {
2675 let baselines = self.state.contract_store.all();
2676 if baselines.is_empty() {
2677 return json_result(&serde_json::json!({
2678 "checked": 0,
2679 "message": "no contract baselines recorded — use contract_record first",
2680 }));
2681 }
2682 let mut results = Vec::new();
2683 for baseline in &baselines {
2684 if !self.state.privacy.is_invoke_allowed(&baseline.command)
2687 || !self.state.privacy.is_command_allowed(&baseline.command)
2688 {
2689 continue;
2690 }
2691 let args_str =
2692 serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2693 let code = format!(
2694 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2695 js_string(&baseline.command)
2696 );
2697 match self
2698 .eval_with_return(&code, params.webview_label.as_deref())
2699 .await
2700 {
2701 Ok(result_str) => {
2702 let value: serde_json::Value = serde_json::from_str(&result_str)
2703 .unwrap_or(serde_json::Value::String(result_str));
2704 let current_shape = crate::introspection::JsonShape::from_value(&value);
2705 let drift = crate::introspection::diff_shapes(
2706 &baseline.shape,
2707 ¤t_shape,
2708 &baseline.command,
2709 );
2710 results.push(drift);
2711 }
2712 Err(e) => {
2713 results.push(crate::introspection::ContractDrift {
2714 command: baseline.command.clone(),
2715 new_fields: Vec::new(),
2716 removed_fields: Vec::new(),
2717 type_changes: Vec::new(),
2718 shape_matches: false,
2719 });
2720 tracing::warn!(
2721 command = %baseline.command,
2722 error = %e,
2723 "contract check invocation failed"
2724 );
2725 }
2726 }
2727 }
2728 let passing = results.iter().filter(|r| r.shape_matches).count();
2729 let result = serde_json::json!({
2730 "checked": results.len(),
2731 "passing": passing,
2732 "failing": results.len() - passing,
2733 "contracts": results,
2734 });
2735 json_result(&result)
2736 }
2737 IntrospectAction::ContractList => {
2738 let baselines = self.state.contract_store.all();
2739 let result = serde_json::json!({
2740 "count": baselines.len(),
2741 "baselines": baselines.iter().map(|b| serde_json::json!({
2742 "command": b.command,
2743 "shape_type": b.shape.type_name(),
2744 "recorded_at": b.recorded_at,
2745 })).collect::<Vec<_>>(),
2746 });
2747 json_result(&result)
2748 }
2749 IntrospectAction::ContractClear => {
2750 let cleared = self.state.contract_store.clear();
2751 json_result(&serde_json::json!({
2752 "cleared": cleared,
2753 }))
2754 }
2755 IntrospectAction::StartupTiming => {
2756 let phases = self.state.startup_timeline.report();
2757 let result = serde_json::json!({
2758 "phases": phases,
2759 "total_ms": self.state.startup_timeline.total_ms(),
2760 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2761 });
2762 json_result(&result)
2763 }
2764 IntrospectAction::Capabilities => {
2765 let config = self.bridge.tauri_config();
2766 let live_windows = self.bridge.list_window_labels();
2767
2768 let result = serde_json::json!({
2769 "app": {
2770 "identifier": config.get("identifier"),
2771 "product_name": config.get("product_name"),
2772 "version": config.get("version"),
2773 },
2774 "security": config.get("security"),
2775 "configured_windows": config.get("windows"),
2776 "live_windows": live_windows,
2777 "configured_plugins": config.get("plugins"),
2778 "victauri": {
2779 "registered_commands": self.state.registry.list().len(),
2780 "redaction_enabled": self.state.privacy.redaction_enabled,
2781 "privacy_profile": format!("{:?}", self.state.privacy.profile),
2782 "disabled_tools": &self.state.privacy.disabled_tools,
2783 },
2784 });
2785 json_result(&result)
2786 }
2787 #[allow(unused_variables)]
2788 IntrospectAction::DbHealth => {
2789 #[cfg(feature = "sqlite")]
2790 {
2791 let db_path = params.db_path.clone();
2792 match self.run_db_health(db_path.as_deref()).await {
2793 Ok(health) => json_result(&health),
2794 Err(e) => tool_error(format!("db_health failed: {e}")),
2795 }
2796 }
2797 #[cfg(not(feature = "sqlite"))]
2798 {
2799 tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2800 }
2801 }
2802 IntrospectAction::PluginState => {
2803 let recording_active = self.state.recorder.is_recording();
2804 let recording_events = self.state.recorder.event_count();
2805 let result = serde_json::json!({
2806 "event_log": {
2807 "size": self.state.event_log.len(),
2808 "capacity": self.state.event_log.capacity(),
2809 },
2810 "registry": {
2811 "commands_registered": self.state.registry.list().len(),
2812 },
2813 "recording": {
2814 "active": recording_active,
2815 "events_captured": recording_events,
2816 },
2817 "faults": {
2818 "active_rules": self.state.fault_registry.list().len(),
2819 },
2820 "contracts": {
2821 "baselines_recorded": self.state.contract_store.all().len(),
2822 },
2823 "timings": {
2824 "commands_profiled": self.state.command_timings.all_stats().len(),
2825 },
2826 "event_bus": {
2827 "captured_events": self.state.event_bus.len(),
2828 },
2829 "tasks": {
2830 "total": self.state.task_tracker.list().len(),
2831 "active": self.state.task_tracker.active_count(),
2832 },
2833 "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2834 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2835 "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2836 });
2837 json_result(&result)
2838 }
2839 IntrospectAction::Processes => {
2840 let pid = std::process::id();
2841 let uptime = self.state.started_at.elapsed();
2842 let children = crate::introspection::enumerate_child_processes();
2843 let host_memory = crate::memory::current_stats();
2844
2845 let result = serde_json::json!({
2846 "host": {
2847 "pid": pid,
2848 "uptime_secs": uptime.as_secs(),
2849 "platform": std::env::consts::OS,
2850 "arch": std::env::consts::ARCH,
2851 "memory": host_memory,
2852 },
2853 "children": children.iter().map(|c| serde_json::json!({
2854 "pid": c.pid,
2855 "name": c.name,
2856 "memory_bytes": c.memory_bytes,
2857 })).collect::<Vec<_>>(),
2858 "child_count": children.len(),
2859 "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2860 });
2861 json_result(&result)
2862 }
2863 IntrospectAction::PluginTasks => {
2864 let tasks = self.state.task_tracker.list();
2865 let active = self.state.task_tracker.active_count();
2866 let result = serde_json::json!({
2867 "total": tasks.len(),
2868 "active": active,
2869 "finished": tasks.len() - active,
2870 "tasks": tasks,
2871 });
2872 json_result(&result)
2873 }
2874 IntrospectAction::EventBus => {
2875 let tauri_events = self.state.event_bus.events();
2876 let app_events = self.state.event_log.snapshot();
2877 let result = serde_json::json!({
2878 "tauri_events": {
2879 "count": tauri_events.len(),
2880 "events": tauri_events,
2881 },
2882 "app_events": {
2883 "count": app_events.len(),
2884 "capacity": self.state.event_log.capacity(),
2885 "events": app_events,
2886 },
2887 });
2888 json_result(&result)
2889 }
2890 IntrospectAction::EventBusClear => {
2891 let tauri_cleared = self.state.event_bus.clear();
2892 self.state.event_log.clear();
2893 json_result(&serde_json::json!({
2894 "tauri_events_cleared": tauri_cleared,
2895 "app_events_cleared": true,
2896 }))
2897 }
2898 }
2899 }
2900
2901 #[tool(
2904 description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
2905 Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2906 SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
2907 they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), \
2908 which runs below the layer Victauri can reach. Use this to test a handler's error path when \
2909 YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\
2910 Actions:\n\
2911 - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2912 - `list`: List all active fault injection rules.\n\
2913 - `clear`: Remove a specific fault rule (requires `command`).\n\
2914 - `clear_all`: Remove all fault rules.",
2915 annotations(
2916 read_only_hint = false,
2917 destructive_hint = true,
2918 idempotent_hint = false,
2919 open_world_hint = false
2920 )
2921 )]
2922 async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2923 self.track_tool_call();
2924 if !self.state.privacy.is_tool_enabled("fault") {
2925 return tool_disabled("fault");
2926 }
2927
2928 match params.action {
2929 FaultAction::Inject => {
2930 let Some(command) = params.command else {
2931 return missing_param("command", "inject");
2932 };
2933 let Some(fault_kind) = params.fault_type else {
2934 return missing_param("fault_type", "inject");
2935 };
2936 let fault_type = match fault_kind {
2937 FaultKind::Delay => {
2938 let delay_ms = params.delay_ms.unwrap_or(1000);
2939 crate::introspection::FaultType::Delay { delay_ms }
2940 }
2941 FaultKind::Error => {
2942 let message = params
2943 .error_message
2944 .unwrap_or_else(|| "injected fault".to_string());
2945 crate::introspection::FaultType::Error { message }
2946 }
2947 FaultKind::Drop => crate::introspection::FaultType::Drop,
2948 FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2949 };
2950 let config = crate::introspection::FaultConfig {
2951 command: command.clone(),
2952 fault_type: fault_type.clone(),
2953 trigger_count: 0,
2954 max_triggers: params.max_triggers.unwrap_or(0),
2955 created_at: std::time::Instant::now(),
2956 };
2957 self.state.fault_registry.inject(config);
2958 let result = serde_json::json!({
2959 "injected": true,
2960 "command": command,
2961 "fault_type": fault_type,
2962 "max_triggers": params.max_triggers.unwrap_or(0),
2963 });
2964 json_result(&result)
2965 }
2966 FaultAction::List => {
2967 let faults = self.state.fault_registry.list();
2968 let result = serde_json::json!({
2969 "count": faults.len(),
2970 "faults": faults.iter().map(|f| serde_json::json!({
2971 "command": f.command,
2972 "fault_type": f.fault_type,
2973 "trigger_count": f.trigger_count,
2974 "max_triggers": f.max_triggers,
2975 })).collect::<Vec<_>>(),
2976 });
2977 json_result(&result)
2978 }
2979 FaultAction::Clear => {
2980 let Some(command) = params.command else {
2981 return missing_param("command", "clear");
2982 };
2983 let removed = self.state.fault_registry.clear(&command);
2984 json_result(&serde_json::json!({
2985 "removed": removed,
2986 "command": command,
2987 }))
2988 }
2989 FaultAction::ClearAll => {
2990 let removed = self.state.fault_registry.clear_all();
2991 json_result(&serde_json::json!({
2992 "removed": removed,
2993 }))
2994 }
2995 }
2996 }
2997
2998 #[tool(
3001 description = "Correlate recent activity across all layers into a coherent narrative. \
3002 CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
3003 + window events across the Rust backend and webview simultaneously.\n\n\
3004 Actions:\n\
3005 - `summary`: High-level activity summary for the last N seconds (default 30). \
3006 Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
3007 - `last_action`: Correlate the most recent burst of events into a causal timeline \
3008 (e.g. 'IPC call → DOM update → console.log').\n\
3009 - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
3010 annotations(
3011 read_only_hint = true,
3012 destructive_hint = false,
3013 idempotent_hint = true,
3014 open_world_hint = false
3015 )
3016 )]
3017 async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
3018 self.track_tool_call();
3019 if !self.state.privacy.is_tool_enabled("explain") {
3020 return tool_disabled("explain");
3021 }
3022
3023 match params.action {
3024 ExplainAction::Summary => {
3025 let secs = params.seconds.unwrap_or(30);
3026 let since = chrono::Utc::now()
3027 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3028 let events = self.state.event_log.since(since);
3029
3030 let mut ipc_count = 0u64;
3031 let mut dom_mutations = 0u64;
3032 let mut state_changes = 0u64;
3033 let mut console_count = 0u64;
3034 let mut window_events = 0u64;
3035 let mut interactions = 0u64;
3036 let mut top_commands: HashMap<String, u64> = HashMap::new();
3037 let mut errors: Vec<String> = Vec::new();
3038
3039 for event in &events {
3040 match event {
3041 victauri_core::AppEvent::Ipc(call) => {
3042 ipc_count += 1;
3043 *top_commands.entry(call.command.clone()).or_insert(0) += 1;
3044 if let victauri_core::IpcResult::Err(e) = &call.result {
3045 errors.push(format!("IPC {}: {e}", call.command));
3046 }
3047 }
3048 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3049 dom_mutations += u64::from(*mutation_count)
3050 }
3051 victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
3052 victauri_core::AppEvent::Console { level, message, .. } => {
3053 console_count += 1;
3054 if level == "error" {
3055 errors.push(format!("console.error: {message}"));
3056 }
3057 }
3058 victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
3059 victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
3060 _ => {}
3061 }
3062 }
3063
3064 let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
3065 sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
3066 let top: Vec<_> = sorted_cmds.iter().take(5).collect();
3067
3068 let narrative = format!(
3069 "{ipc_count} IPC call{} in the last {secs}s{}. \
3070 {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
3071 {console_count} console message{}, {window_events} window event{}. {}.",
3072 if ipc_count == 1 { "" } else { "s" },
3073 if top.is_empty() {
3074 String::new()
3075 } else {
3076 format!(
3077 ", dominated by {}",
3078 top.iter()
3079 .map(|(cmd, n)| format!("{cmd} ({n}x)"))
3080 .collect::<Vec<_>>()
3081 .join(", ")
3082 )
3083 },
3084 if dom_mutations == 1 { "" } else { "s" },
3085 if interactions == 1 { "" } else { "s" },
3086 if console_count == 1 { "" } else { "s" },
3087 if window_events == 1 { "" } else { "s" },
3088 if errors.is_empty() {
3089 "No errors".to_string()
3090 } else {
3091 format!(
3092 "{} error{}",
3093 errors.len(),
3094 if errors.len() == 1 { "" } else { "s" }
3095 )
3096 },
3097 );
3098
3099 let result = serde_json::json!({
3100 "time_window_secs": secs,
3101 "total_events": events.len(),
3102 "ipc_calls": ipc_count,
3103 "dom_mutations": dom_mutations,
3104 "state_changes": state_changes,
3105 "console_messages": console_count,
3106 "window_events": window_events,
3107 "interactions": interactions,
3108 "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
3109 serde_json::json!({"command": cmd, "count": n})
3110 }).collect::<Vec<_>>(),
3111 "errors": errors,
3112 "narrative": narrative,
3113 });
3114 json_result(&result)
3115 }
3116 ExplainAction::LastAction => {
3117 let secs = params.seconds.unwrap_or(5);
3118 let since = chrono::Utc::now()
3119 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3120 let events = self.state.event_log.since(since);
3121
3122 let timeline: Vec<serde_json::Value> = events
3123 .iter()
3124 .filter(|e| !e.is_internal())
3125 .map(|event| match event {
3126 victauri_core::AppEvent::Ipc(call) => serde_json::json!({
3127 "time": call.timestamp.to_rfc3339_opts(
3128 chrono::SecondsFormat::Millis, true
3129 ),
3130 "type": "ipc",
3131 "detail": format!(
3132 "{} {} ({}ms)",
3133 call.command,
3134 call.result,
3135 call.duration_ms.unwrap_or(0)
3136 ),
3137 }),
3138 victauri_core::AppEvent::DomMutation {
3139 timestamp,
3140 mutation_count,
3141 webview_label,
3142 } => serde_json::json!({
3143 "time": timestamp.to_rfc3339_opts(
3144 chrono::SecondsFormat::Millis, true
3145 ),
3146 "type": "dom_mutation",
3147 "detail": format!(
3148 "{mutation_count} element{} updated in {webview_label}",
3149 if *mutation_count == 1 { "" } else { "s" }
3150 ),
3151 }),
3152 victauri_core::AppEvent::DomInteraction {
3153 timestamp,
3154 action,
3155 selector,
3156 ..
3157 } => serde_json::json!({
3158 "time": timestamp.to_rfc3339_opts(
3159 chrono::SecondsFormat::Millis, true
3160 ),
3161 "type": "interaction",
3162 "detail": format!("{action} on {selector}"),
3163 }),
3164 victauri_core::AppEvent::StateChange {
3165 timestamp,
3166 key,
3167 caused_by,
3168 } => serde_json::json!({
3169 "time": timestamp.to_rfc3339_opts(
3170 chrono::SecondsFormat::Millis, true
3171 ),
3172 "type": "state_change",
3173 "detail": format!(
3174 "{key} changed{}",
3175 caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
3176 ),
3177 }),
3178 victauri_core::AppEvent::Console {
3179 timestamp,
3180 level,
3181 message,
3182 } => serde_json::json!({
3183 "time": timestamp.to_rfc3339_opts(
3184 chrono::SecondsFormat::Millis, true
3185 ),
3186 "type": "console",
3187 "detail": format!("console.{level}: {message}"),
3188 }),
3189 victauri_core::AppEvent::WindowEvent {
3190 timestamp,
3191 label,
3192 event,
3193 } => serde_json::json!({
3194 "time": timestamp.to_rfc3339_opts(
3195 chrono::SecondsFormat::Millis, true
3196 ),
3197 "type": "window_event",
3198 "detail": format!("{event} on window '{label}'"),
3199 }),
3200 _ => serde_json::json!({
3201 "time": event.timestamp().to_rfc3339_opts(
3202 chrono::SecondsFormat::Millis, true
3203 ),
3204 "type": "other",
3205 "detail": "unknown event type",
3206 }),
3207 })
3208 .collect();
3209
3210 let narrative = if timeline.is_empty() {
3211 format!("No activity in the last {secs}s.")
3212 } else {
3213 let parts: Vec<String> = timeline
3214 .iter()
3215 .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
3216 .map(String::from)
3217 .collect();
3218 parts.join(" → ")
3219 };
3220
3221 let result = serde_json::json!({
3222 "time_window_secs": secs,
3223 "event_count": timeline.len(),
3224 "timeline": timeline,
3225 "narrative": narrative,
3226 });
3227 json_result(&result)
3228 }
3229 ExplainAction::Diff => {
3230 let secs = params.seconds.unwrap_or(10);
3231 let since = chrono::Utc::now()
3232 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3233 let events = self.state.event_log.since(since);
3234
3235 let mut ipc_commands: Vec<String> = Vec::new();
3236 let mut dom_changes = 0u64;
3237 let mut error_count = 0u64;
3238 let mut interaction_count = 0u64;
3239 let mut console_messages = 0u64;
3240
3241 for event in &events {
3242 if event.is_internal() {
3243 continue;
3244 }
3245 match event {
3246 victauri_core::AppEvent::Ipc(call) => {
3247 ipc_commands.push(call.command.clone());
3248 if matches!(call.result, victauri_core::IpcResult::Err(_)) {
3249 error_count += 1;
3250 }
3251 }
3252 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3253 dom_changes += u64::from(*mutation_count)
3254 }
3255 victauri_core::AppEvent::DomInteraction { .. } => {
3256 interaction_count += 1;
3257 }
3258 victauri_core::AppEvent::Console { level, .. } => {
3259 console_messages += 1;
3260 if level == "error" {
3261 error_count += 1;
3262 }
3263 }
3264 _ => {}
3265 }
3266 }
3267
3268 ipc_commands.dedup();
3269
3270 let result = serde_json::json!({
3271 "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3272 "time_window_secs": secs,
3273 "total_events": events.len(),
3274 "ipc_calls_made": ipc_commands.len(),
3275 "unique_commands": ipc_commands,
3276 "dom_elements_changed": dom_changes,
3277 "interactions": interaction_count,
3278 "console_messages": console_messages,
3279 "errors": error_count,
3280 });
3281 json_result(&result)
3282 }
3283 }
3284 }
3285}
3286
3287impl VictauriMcpHandler {
3288 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
3290 Self {
3291 state,
3292 bridge,
3293 subscriptions: Arc::new(Mutex::new(HashSet::new())),
3294 bridge_checked: Arc::new(AtomicBool::new(false)),
3295 probed_labels: Arc::new(Mutex::new(HashSet::new())),
3296 timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
3297 }
3298 }
3299
3300 pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
3301 self.state.privacy.is_tool_enabled(name)
3302 }
3303
3304 pub(crate) async fn execute_tool(
3305 &self,
3306 name: &str,
3307 args: serde_json::Value,
3308 ) -> Result<CallToolResult, rest::ToolCallError> {
3309 if !self.state.privacy.is_tool_enabled(name) {
3310 return Ok(tool_disabled(name));
3311 }
3312 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3313 let start = std::time::Instant::now();
3314 tracing::debug!(tool = %name, "REST tool invocation started");
3315
3316 let result = match name {
3317 "eval_js" => {
3318 let p: EvalJsParams = Self::parse_args(args)?;
3319 self.eval_js(Parameters(p)).await
3320 }
3321 "dom_snapshot" => {
3322 let p: SnapshotParams = Self::parse_args(args)?;
3323 self.dom_snapshot(Parameters(p)).await
3324 }
3325 "find_elements" => {
3326 let p: FindElementsParams = Self::parse_args(args)?;
3327 self.find_elements(Parameters(p)).await
3328 }
3329 "invoke_command" => {
3330 let p: InvokeCommandParams = Self::parse_args(args)?;
3331 self.invoke_command(Parameters(p)).await
3332 }
3333 "screenshot" => {
3334 let p: ScreenshotParams = Self::parse_args(args)?;
3335 self.screenshot(Parameters(p)).await
3336 }
3337 "verify_state" => {
3338 let p: VerifyStateParams = Self::parse_args(args)?;
3339 self.verify_state(Parameters(p)).await
3340 }
3341 "detect_ghost_commands" => {
3342 let p: GhostCommandParams = Self::parse_args(args)?;
3343 self.detect_ghost_commands(Parameters(p)).await
3344 }
3345 "check_ipc_integrity" => {
3346 let p: IpcIntegrityParams = Self::parse_args(args)?;
3347 self.check_ipc_integrity(Parameters(p)).await
3348 }
3349 "wait_for" => {
3350 let p: WaitForParams = Self::parse_args(args)?;
3351 self.wait_for(Parameters(p)).await
3352 }
3353 "assert_semantic" => {
3354 let p: SemanticAssertParams = Self::parse_args(args)?;
3355 self.assert_semantic(Parameters(p)).await
3356 }
3357 "resolve_command" => {
3358 let p: ResolveCommandParams = Self::parse_args(args)?;
3359 self.resolve_command(Parameters(p)).await
3360 }
3361 "get_registry" => {
3362 let p: RegistryParams = Self::parse_args(args)?;
3363 self.get_registry(Parameters(p)).await
3364 }
3365 "get_memory_stats" => self.get_memory_stats().await,
3366 "get_plugin_info" => self.get_plugin_info().await,
3367 "get_diagnostics" => {
3368 let p: DiagnosticsParams = Self::parse_args(args)?;
3369 self.get_diagnostics(Parameters(p)).await
3370 }
3371 "app_info" => self.app_info().await,
3372 "list_app_dir" => {
3373 let p: ListAppDirParams = Self::parse_args(args)?;
3374 self.list_app_dir(Parameters(p)).await
3375 }
3376 "read_app_file" => {
3377 let p: ReadAppFileParams = Self::parse_args(args)?;
3378 self.read_app_file(Parameters(p)).await
3379 }
3380 #[cfg(feature = "sqlite")]
3381 "query_db" => {
3382 let p: QueryDbParams = Self::parse_args(args)?;
3383 self.query_db(Parameters(p)).await
3384 }
3385 "interact" => {
3386 let p: InteractParams = Self::parse_args(args)?;
3387 self.interact(Parameters(p)).await
3388 }
3389 "input" => {
3390 let p: InputParams = Self::parse_args(args)?;
3391 self.input(Parameters(p)).await
3392 }
3393 "window" => {
3394 let p: WindowParams = Self::parse_args(args)?;
3395 self.window(Parameters(p)).await
3396 }
3397 "storage" => {
3398 let p: StorageParams = Self::parse_args(args)?;
3399 self.storage(Parameters(p)).await
3400 }
3401 "navigate" => {
3402 let p: NavigateParams = Self::parse_args(args)?;
3403 self.navigate(Parameters(p)).await
3404 }
3405 "recording" => {
3406 let p: RecordingParams = Self::parse_args(args)?;
3407 self.recording(Parameters(p)).await
3408 }
3409 "inspect" => {
3410 let p: InspectParams = Self::parse_args(args)?;
3411 self.inspect(Parameters(p)).await
3412 }
3413 "css" => {
3414 let p: CssParams = Self::parse_args(args)?;
3415 self.css(Parameters(p)).await
3416 }
3417 "route" => {
3418 let p: RouteParams = Self::parse_args(args)?;
3419 self.route(Parameters(p)).await
3420 }
3421 "trace" => {
3422 let p: TraceParams = Self::parse_args(args)?;
3423 self.trace(Parameters(p)).await
3424 }
3425 "animation" => {
3426 let p: AnimationParams = Self::parse_args(args)?;
3427 self.animation(Parameters(p)).await
3428 }
3429 "logs" => {
3430 let p: LogsParams = Self::parse_args(args)?;
3431 self.logs(Parameters(p)).await
3432 }
3433 "introspect" => {
3434 let p: IntrospectParams = Self::parse_args(args)?;
3435 self.introspect(Parameters(p)).await
3436 }
3437 "fault" => {
3438 let p: FaultParams = Self::parse_args(args)?;
3439 self.fault(Parameters(p)).await
3440 }
3441 "explain" => {
3442 let p: ExplainParams = Self::parse_args(args)?;
3443 self.explain(Parameters(p)).await
3444 }
3445 _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3446 };
3447
3448 let elapsed = start.elapsed();
3449 tracing::debug!(
3450 tool = %name,
3451 elapsed_ms = elapsed.as_millis() as u64,
3452 "REST tool invocation completed"
3453 );
3454
3455 if self.state.privacy.redaction_enabled {
3456 Ok(Self::redact_result(result, &self.state.privacy))
3457 } else {
3458 Ok(result)
3459 }
3460 }
3461
3462 fn parse_args<T: serde::de::DeserializeOwned>(
3463 args: serde_json::Value,
3464 ) -> Result<T, rest::ToolCallError> {
3465 serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3466 }
3467
3468 fn redact_result(
3469 mut result: CallToolResult,
3470 privacy: &crate::privacy::PrivacyConfig,
3471 ) -> CallToolResult {
3472 for item in &mut result.content {
3473 if let RawContent::Text(ref mut tc) = item.raw {
3474 tc.text = privacy.redact_output(&tc.text);
3475 }
3476 }
3477 result
3478 }
3479
3480 fn track_tool_call(&self) {
3481 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3482 }
3483
3484 fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3485 match dir.unwrap_or(AppDir::Data) {
3486 AppDir::Data => self.bridge.app_data_dir(),
3487 AppDir::Config => self.bridge.app_config_dir(),
3488 AppDir::Log => self.bridge.app_log_dir(),
3489 AppDir::LocalData => self.bridge.app_local_data_dir(),
3490 }
3491 }
3492
3493 fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
3494 let canon_base = std::fs::canonicalize(base)
3495 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
3496 let canon_target = std::fs::canonicalize(target)
3497 .map_err(|e| format!("cannot resolve target path: {e}"))?;
3498 if !canon_target.starts_with(&canon_base) {
3499 return Err("path traversal not allowed".to_string());
3500 }
3501 Ok(())
3502 }
3503
3504 fn list_dir_recursive(
3505 dir: &std::path::Path,
3506 base: &std::path::Path,
3507 depth: u32,
3508 max_depth: u32,
3509 pattern: Option<&str>,
3510 entries: &mut Vec<serde_json::Value>,
3511 ) {
3512 let Ok(read_dir) = std::fs::read_dir(dir) else {
3513 return;
3514 };
3515 for entry in read_dir.flatten() {
3516 let path = entry.path();
3517 if path.is_symlink() {
3518 continue;
3519 }
3520 let name = entry.file_name().to_string_lossy().into_owned();
3521 let relative = path
3522 .strip_prefix(base)
3523 .unwrap_or(&path)
3524 .to_string_lossy()
3525 .into_owned();
3526
3527 if let Some(pat) = pattern
3528 && !Self::matches_glob(&name, pat)
3529 && !path.is_dir()
3530 {
3531 continue;
3532 }
3533
3534 let is_dir = path.is_dir();
3535 let meta = std::fs::metadata(&path).ok();
3536
3537 entries.push(serde_json::json!({
3538 "name": name,
3539 "path": relative,
3540 "is_dir": is_dir,
3541 "size": meta.as_ref().map(std::fs::Metadata::len),
3542 "modified": meta.as_ref()
3543 .and_then(|m| m.modified().ok())
3544 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
3545 .unwrap_or_default().as_secs()),
3546 }));
3547
3548 if is_dir && depth < max_depth {
3549 Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
3550 }
3551 }
3552 }
3553
3554 fn matches_glob(name: &str, pattern: &str) -> bool {
3555 if pattern == "*" {
3556 return true;
3557 }
3558 if let Some(suffix) = pattern.strip_prefix("*.") {
3559 return name.ends_with(&format!(".{suffix}"));
3560 }
3561 if let Some(prefix) = pattern.strip_suffix("*") {
3562 return name.starts_with(prefix);
3563 }
3564 name == pattern
3565 }
3566
3567 async fn window_introspectability(&self) -> CallToolResult {
3573 let labels = self.bridge.list_window_labels();
3574 let states = self.bridge.get_window_states(None);
3575 let mut report = Vec::with_capacity(labels.len());
3576 let mut blind = 0usize;
3577 for label in &labels {
3578 let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
3579 let introspectable = self.probe_bridge(Some(label)).await.is_ok();
3580 if !introspectable {
3581 blind += 1;
3582 }
3583 let note = if introspectable {
3584 "ok — Victauri JS bridge is responding".to_string()
3585 } else if visible == Some(true) {
3586 format!(
3587 "NOT introspectable although the window is visible — almost certainly missing \
3588 the Victauri capability. Add \"victauri:default\" to the capability file \
3589 (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
3590 then rebuild. Capabilities are baked at compile time, so a rebuild is required."
3591 )
3592 } else {
3593 "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
3594 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
3595 .to_string()
3596 };
3597 report.push(serde_json::json!({
3598 "label": label,
3599 "visible": visible,
3600 "introspectable": introspectable,
3601 "note": note,
3602 }));
3603 }
3604 let hint = if blind > 0 {
3605 "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
3606 dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
3607 missing \"victauri:default\" capability for that window: Tauri's per-window permission \
3608 ACL silently blocks the bridge's callback IPC. This capability is required per window, \
3609 not just for the main window. (Note: probing a blind window takes ~2s each.)"
3610 } else {
3611 "All windows are introspectable."
3612 };
3613 json_result(&serde_json::json!({
3614 "windows": report,
3615 "introspectable_count": labels.len().saturating_sub(blind),
3616 "blind_count": blind,
3617 "hint": hint,
3618 }))
3619 }
3620
3621 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
3622 match self.eval_with_return(code, webview_label).await {
3623 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
3624 Err(e) => tool_error(e),
3625 }
3626 }
3627
3628 async fn eval_with_return(
3629 &self,
3630 code: &str,
3631 webview_label: Option<&str>,
3632 ) -> Result<String, String> {
3633 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
3634 .await
3635 }
3636
3637 async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
3638 let id = uuid::Uuid::new_v4().to_string();
3639 let (tx, rx) = tokio::sync::oneshot::channel();
3640 {
3641 let mut pending = self.state.pending_evals.lock().await;
3642 pending.insert(id.clone(), tx);
3643 }
3644 let id_js = js_string(&id);
3645 let probe = format!(
3646 r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
3647 );
3648 if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
3649 self.state.pending_evals.lock().await.remove(&id);
3650 return Err(format!("eval injection failed: {e}"));
3651 }
3652 if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
3653 Ok(())
3654 } else {
3655 self.state.pending_evals.lock().await.remove(&id);
3656 let label = webview_label.unwrap_or("default");
3657 Err(format!(
3658 "bridge not responding on window '{label}' — the window may be hidden, \
3659 missing the victauri capability, or the JS bridge is not loaded"
3660 ))
3661 }
3662 }
3663
3664 async fn eval_with_return_timeout(
3665 &self,
3666 code: &str,
3667 webview_label: Option<&str>,
3668 timeout: std::time::Duration,
3669 ) -> Result<String, String> {
3670 self.track_tool_call();
3671
3672 if !self
3677 .state
3678 .bridge_ready
3679 .load(std::sync::atomic::Ordering::Acquire)
3680 {
3681 let notified = self.state.bridge_notify.notified();
3682 if !self
3683 .state
3684 .bridge_ready
3685 .load(std::sync::atomic::Ordering::Acquire)
3686 {
3687 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
3688 }
3689 }
3690
3691 let label_key =
3694 webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
3695
3696 if webview_label.is_some() {
3699 let already_probed = self.probed_labels.lock().await.contains(&label_key);
3700 if !already_probed {
3701 self.probe_bridge(webview_label).await?;
3702 self.probed_labels.lock().await.insert(label_key.clone());
3703 }
3704 }
3705
3706 if self.timed_out_labels.lock().await.remove(&label_key) {
3713 self.probe_bridge(webview_label).await.map_err(|e| {
3714 format!("{e} (previous eval on this window timed out; the webview may have reloaded or the app stopped responding)")
3715 })?;
3716 }
3717
3718 let id = uuid::Uuid::new_v4().to_string();
3719 let (tx, rx) = tokio::sync::oneshot::channel();
3720
3721 {
3722 let mut pending = self.state.pending_evals.lock().await;
3723 if pending.len() >= MAX_PENDING_EVALS {
3724 return Err(format!(
3725 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
3726 ));
3727 }
3728 pending.insert(id.clone(), tx);
3729 }
3730
3731 let code = if should_prepend_return(code) {
3738 format!("return {}", code.trim())
3739 } else {
3740 code.trim().to_string()
3741 };
3742
3743 let id_js = js_string(&id);
3744
3745 let watchdog = format!(
3758 r"
3759 (function () {{
3760 window.__VIC_EVAL__ = window.__VIC_EVAL__ || {{}};
3761 var s = (window.__VIC_EVAL__[{id_js}] =
3762 window.__VIC_EVAL__[{id_js}] || {{ started: false, done: false }});
3763 setTimeout(function () {{
3764 if (s.started || s.done) return;
3765 s.done = true;
3766 try {{
3767 window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3768 id: {id_js},
3769 result: JSON.stringify({{ __victauri_err: 'code did not begin executing within {PARSE_WATCHDOG_MS}ms — this almost always means a syntax/parse error in the submitted code (or the page main thread was blocked)' }})
3770 }});
3771 }} catch (e) {{}}
3772 delete window.__VIC_EVAL__[{id_js}];
3773 }}, {PARSE_WATCHDOG_MS});
3774 }})();
3775 "
3776 );
3777
3778 let inject = format!(
3779 r"
3780 (async () => {{
3781 var __s = (window.__VIC_EVAL__ && window.__VIC_EVAL__[{id_js}]) || null;
3782 if (__s) __s.started = true;
3783 try {{
3784 const __result = await (async () => {{ {code} }})();
3785 if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
3786 const __type = __result === undefined ? 'undefined'
3787 : __result === null ? 'null' : 'value';
3788 const __val = __type === 'undefined' ? null
3789 : __type === 'null' ? null : __result;
3790 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3791 id: {id_js},
3792 result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
3793 }});
3794 }} catch (e) {{
3795 if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
3796 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3797 id: {id_js},
3798 result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
3799 }});
3800 }}
3801 }})();
3802 "
3803 );
3804
3805 if let Err(e) = self.bridge.eval_webview(webview_label, &watchdog) {
3809 self.state.pending_evals.lock().await.remove(&id);
3810 return Err(format!("eval injection failed: {e}"));
3811 }
3812 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
3813 self.state.pending_evals.lock().await.remove(&id);
3814 return Err(format!("eval injection failed: {e}"));
3815 }
3816
3817 match tokio::time::timeout(timeout, rx).await {
3818 Ok(Ok(raw)) => {
3819 self.check_bridge_version_once();
3820 if raw.len() > MAX_EVAL_RESULT_LEN {
3821 return Err(format!(
3822 "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
3823 raw.len()
3824 ));
3825 }
3826 unwrap_eval_envelope(raw)
3827 }
3828 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
3829 Err(_) => {
3830 self.state.pending_evals.lock().await.remove(&id);
3831 self.timed_out_labels.lock().await.insert(label_key.clone());
3835 Err(format!(
3836 "eval timed out after {}s — the code began executing but never resolved. \
3837 (A syntax/parse error would have failed fast via the parse watchdog, so \
3838 this is NOT a parse error.) Common causes: an unresolved promise, an \
3839 infinite loop, an `await` on something that never settles, or the webview \
3840 reloaded / the app stopped responding mid-eval. If the app may have \
3841 navigated or crashed, retry (the next call fails fast if the bridge is \
3842 gone).",
3843 timeout.as_secs()
3844 ))
3845 }
3846 }
3847 }
3848
3849 #[cfg(feature = "sqlite")]
3850 async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
3851 let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
3853 for d in [
3854 self.bridge.app_data_dir(),
3855 self.bridge.app_local_data_dir(),
3856 self.bridge.app_config_dir(),
3857 ]
3858 .into_iter()
3859 .flatten()
3860 {
3861 roots.push(d);
3862 }
3863
3864 let path = if let Some(p) = db_path {
3865 let candidate = std::path::Path::new(p);
3866 if candidate.is_absolute() {
3867 if !roots
3868 .iter()
3869 .any(|r| Self::safe_within(r, candidate).is_ok())
3870 {
3871 return Err(format!(
3872 "absolute path '{p}' is not within an allowed directory; \
3873 register its parent via VictauriBuilder::db_search_paths"
3874 ));
3875 }
3876 candidate.to_path_buf()
3877 } else {
3878 roots
3879 .iter()
3880 .map(|r| r.join(p))
3881 .find(|c| c.exists())
3882 .ok_or_else(|| format!("database not found: {p}"))?
3883 }
3884 } else {
3885 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
3889 roots.clone()
3890 } else {
3891 self.state.db_search_paths.clone()
3892 };
3893 crate::database::select_app_database(&select_dirs)?
3894 };
3895 let path_str = path
3901 .to_str()
3902 .ok_or_else(|| "invalid path encoding".to_string())?
3903 .to_string();
3904
3905 tokio::task::spawn_blocking(move || {
3906 let conn = rusqlite::Connection::open_with_flags(
3907 &path_str,
3908 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
3909 )
3910 .map_err(|e| format!("cannot open database: {e}"))?;
3911
3912 let journal_mode: String = conn
3913 .pragma_query_value(None, "journal_mode", |r| r.get(0))
3914 .unwrap_or_else(|_| "unknown".to_string());
3915
3916 let page_count: i64 = conn
3917 .pragma_query_value(None, "page_count", |r| r.get(0))
3918 .unwrap_or(0);
3919
3920 let page_size: i64 = conn
3921 .pragma_query_value(None, "page_size", |r| r.get(0))
3922 .unwrap_or(0);
3923
3924 let freelist_count: i64 = conn
3925 .pragma_query_value(None, "freelist_count", |r| r.get(0))
3926 .unwrap_or(0);
3927
3928 let wal_checkpoint: String = if journal_mode == "wal" {
3929 let mut info = String::from("n/a");
3930 let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
3931 let busy: i64 = r.get(0)?;
3932 let checkpointed: i64 = r.get(1)?;
3933 let total: i64 = r.get(2)?;
3934 info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
3935 Ok(())
3936 });
3937 info
3938 } else {
3939 "n/a (not WAL mode)".to_string()
3940 };
3941
3942 let integrity: String = conn
3943 .pragma_query_value(None, "quick_check", |r| r.get(0))
3944 .unwrap_or_else(|_| "failed".to_string());
3945
3946 let db_size_bytes = page_count * page_size;
3947 let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
3948
3949 let mut tables = Vec::new();
3950 if let Ok(mut stmt) =
3951 conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
3952 && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
3953 {
3954 for name in rows.flatten() {
3955 let count: i64 = conn
3956 .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
3957 .unwrap_or(0);
3958 tables.push(serde_json::json!({
3959 "name": name,
3960 "row_count": count,
3961 }));
3962 }
3963 }
3964
3965 Ok(serde_json::json!({
3966 "database": path_str,
3967 "journal_mode": journal_mode,
3968 "page_count": page_count,
3969 "page_size": page_size,
3970 "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
3971 "freelist_count": freelist_count,
3972 "wal_checkpoint": wal_checkpoint,
3973 "integrity_check": integrity,
3974 "tables": tables,
3975 }))
3976 })
3977 .await
3978 .map_err(|e| format!("db health task failed: {e}"))?
3979 }
3980
3981 fn check_bridge_version_once(&self) {
3982 if self.bridge_checked.swap(true, Ordering::Relaxed) {
3983 return;
3984 }
3985 let handler = self.clone();
3986 tokio::spawn(async move {
3987 match handler
3988 .eval_with_return_timeout(
3989 "window.__VICTAURI__?.version",
3990 None,
3991 std::time::Duration::from_secs(5),
3992 )
3993 .await
3994 {
3995 Ok(v) => {
3996 let v = v.trim_matches('"');
3997 if v == BRIDGE_VERSION {
3998 tracing::debug!("Bridge version verified: {v}");
3999 } else {
4000 tracing::warn!(
4001 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
4002 );
4003 }
4004 }
4005 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
4006 }
4007 });
4008 }
4009}
4010
4011const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
4012It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
4013(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
4014(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
4015\n\nBACKEND tools (direct Rust access, no webview needed): \
4016'app_info' (app config, directory paths, discovered databases, process info), \
4017'list_app_dir' (browse app data/config/log directories), \
4018'read_app_file' (read files from app directories), \
4019'query_db' (read-only SQLite queries with auto-discovery). \
4020\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
4021'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
4022capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
4023Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
4024capability/security auditing, database diagnostics, plugin state, child process enumeration, \
4025task tracking, and automatic Tauri event bus monitoring. \
4026'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
4027drops, and response corruption into Tauri commands at the Rust layer. \
4028'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
4029activity across IPC + DOM + console + network + window events into a coherent narrative. \
4030\n\nWEBVIEW tools: \
4031'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
4032'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
4033'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
4034\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
4035\n\nCOMPOUND tools with an 'action' parameter: \
4036'window' (get_state, list, manage, resize, move_to, set_title), \
4037'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
4038set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
4039get_events, events_between, get_replay, export, import, replay), \
4040'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
4041\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
4042get_memory_stats, get_plugin_info, get_diagnostics.";
4043
4044impl ServerHandler for VictauriMcpHandler {
4045 fn get_info(&self) -> ServerInfo {
4046 ServerInfo::new(
4047 ServerCapabilities::builder()
4048 .enable_tools()
4049 .enable_resources()
4050 .enable_resources_subscribe()
4051 .build(),
4052 )
4053 .with_instructions(SERVER_INSTRUCTIONS)
4054 }
4055
4056 async fn list_tools(
4057 &self,
4058 _request: Option<PaginatedRequestParams>,
4059 _context: RequestContext<RoleServer>,
4060 ) -> Result<ListToolsResult, ErrorData> {
4061 let all_tools = Self::tool_router().list_all();
4062 let filtered: Vec<Tool> = all_tools
4063 .into_iter()
4064 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
4065 .collect();
4066 Ok(ListToolsResult {
4067 tools: filtered,
4068 ..Default::default()
4069 })
4070 }
4071
4072 async fn call_tool(
4073 &self,
4074 request: CallToolRequestParams,
4075 context: RequestContext<RoleServer>,
4076 ) -> Result<CallToolResult, ErrorData> {
4077 let tool_name: String = request.name.as_ref().to_owned();
4078 if !self.state.privacy.is_tool_enabled(&tool_name) {
4079 tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
4080 return Ok(tool_disabled(&tool_name));
4081 }
4082 self.state
4083 .tool_invocations
4084 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4085 let start = std::time::Instant::now();
4086 tracing::debug!(tool = %tool_name, "tool invocation started");
4087 let ctx = ToolCallContext::new(self, request, context);
4088 let result = Self::tool_router().call(ctx).await;
4089 let elapsed = start.elapsed();
4090 tracing::debug!(
4091 tool = %tool_name,
4092 elapsed_ms = elapsed.as_millis() as u64,
4093 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
4094 "tool invocation completed"
4095 );
4096
4097 if self.state.privacy.redaction_enabled {
4100 result.map(|mut r| {
4101 for item in &mut r.content {
4102 if let RawContent::Text(ref mut tc) = item.raw {
4103 tc.text = self.state.privacy.redact_output(&tc.text);
4104 }
4105 }
4106 r
4107 })
4108 } else {
4109 result
4110 }
4111 }
4112
4113 fn get_tool(&self, name: &str) -> Option<Tool> {
4114 if !self.state.privacy.is_tool_enabled(name) {
4115 return None;
4116 }
4117 Self::tool_router().get(name).cloned()
4118 }
4119
4120 async fn list_resources(
4121 &self,
4122 _request: Option<PaginatedRequestParams>,
4123 _context: RequestContext<RoleServer>,
4124 ) -> Result<ListResourcesResult, ErrorData> {
4125 Ok(ListResourcesResult {
4126 resources: vec![
4127 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
4128 .with_description(
4129 "Live IPC call log — all commands invoked between frontend and backend",
4130 )
4131 .with_mime_type("application/json")
4132 .no_annotation(),
4133 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
4134 .with_description(
4135 "Current state of all Tauri windows — position, size, visibility, focus",
4136 )
4137 .with_mime_type("application/json")
4138 .no_annotation(),
4139 RawResource::new(RESOURCE_URI_STATE, "state")
4140 .with_description(
4141 "Victauri plugin state — event count, registered commands, memory stats",
4142 )
4143 .with_mime_type("application/json")
4144 .no_annotation(),
4145 ],
4146 ..Default::default()
4147 })
4148 }
4149
4150 async fn read_resource(
4151 &self,
4152 request: ReadResourceRequestParams,
4153 _context: RequestContext<RoleServer>,
4154 ) -> Result<ReadResourceResult, ErrorData> {
4155 let uri = &request.uri;
4156 let json = match uri.as_str() {
4157 RESOURCE_URI_IPC_LOG => {
4158 if let Ok(json) = self
4159 .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
4160 .await
4161 {
4162 json
4163 } else {
4164 let calls = self.state.event_log.ipc_calls();
4165 serde_json::to_string_pretty(&calls)
4166 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4167 }
4168 }
4169 RESOURCE_URI_WINDOWS => {
4170 let states = self.bridge.get_window_states(None);
4171 serde_json::to_string_pretty(&states)
4172 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4173 }
4174 RESOURCE_URI_STATE => {
4175 let state_json = serde_json::json!({
4176 "events_captured": self.state.event_log.len(),
4177 "commands_registered": self.state.registry.count(),
4178 "memory": crate::memory::current_stats(),
4179 "port": self.state.port.load(Ordering::Relaxed),
4180 });
4181 serde_json::to_string_pretty(&state_json)
4182 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4183 }
4184 _ => {
4185 return Err(ErrorData::resource_not_found(
4186 format!("unknown resource: {uri}"),
4187 None,
4188 ));
4189 }
4190 };
4191
4192 let json = if self.state.privacy.redaction_enabled {
4193 self.state.privacy.redact_output(&json)
4194 } else {
4195 json
4196 };
4197
4198 Ok(ReadResourceResult::new(vec![ResourceContents::text(
4199 json, uri,
4200 )]))
4201 }
4202
4203 async fn subscribe(
4204 &self,
4205 request: SubscribeRequestParams,
4206 _context: RequestContext<RoleServer>,
4207 ) -> Result<(), ErrorData> {
4208 let uri = &request.uri;
4209 match uri.as_str() {
4210 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
4211 self.subscriptions.lock().await.insert(uri.clone());
4212 tracing::info!("Client subscribed to resource: {uri}");
4213 Ok(())
4214 }
4215 _ => Err(ErrorData::resource_not_found(
4216 format!("unknown resource: {uri}"),
4217 None,
4218 )),
4219 }
4220 }
4221
4222 async fn unsubscribe(
4223 &self,
4224 request: UnsubscribeRequestParams,
4225 _context: RequestContext<RoleServer>,
4226 ) -> Result<(), ErrorData> {
4227 self.subscriptions.lock().await.remove(&request.uri);
4228 tracing::info!("Client unsubscribed from resource: {}", request.uri);
4229 Ok(())
4230 }
4231}
4232
4233fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
4240 let mb = MAX_LOG_FIELD_BYTES;
4241 format!(
4242 r"return (function() {{
4243 var MB = {mb};
4244 function trimField(v) {{
4245 if (typeof v === 'string') {{
4246 return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
4247 }}
4248 if (v && typeof v === 'object') {{
4249 var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
4250 if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
4251 }}
4252 return v;
4253 }}
4254 function trimEntry(e) {{
4255 if (e == null || typeof e !== 'object') return e;
4256 var out = Array.isArray(e) ? [] : {{}};
4257 for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
4258 return out;
4259 }}
4260 var arr = {source_expr} || [];
4261 if (arr.length > {limit}) arr = arr.slice(-{limit});
4262 return arr.map(trimEntry);
4263 }})()"
4264 )
4265}
4266
4267fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
4278 if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
4279 if let Some(err) = envelope.get("__victauri_err") {
4280 return Err(format!(
4281 "JavaScript error: {}",
4282 err.as_str().unwrap_or("unknown error")
4283 ));
4284 }
4285 if envelope.get("__victauri_ok").is_some() {
4286 let js_type = envelope
4287 .get("__victauri_type")
4288 .and_then(|t| t.as_str())
4289 .unwrap_or("value");
4290 return match js_type {
4291 "undefined" => Ok("undefined".to_string()),
4292 "null" => Ok("null".to_string()),
4293 _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
4294 .unwrap_or_else(|_| "null".to_string())),
4295 };
4296 }
4297 }
4298 if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
4300 && let Some(idx) = after.rfind(r#","__victauri_type":"#)
4301 {
4302 return Ok(after[..idx].to_string());
4303 }
4304 if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
4305 let msg = after.trim_end_matches('}').trim_matches('"');
4306 return Err(format!("JavaScript error: {msg}"));
4307 }
4308 Ok(raw)
4309}
4310
4311const STMT_STARTS: &[&str] = &[
4313 "return ",
4314 "return;",
4315 "return\n",
4316 "return\t",
4317 "if ",
4318 "if(",
4319 "for ",
4320 "for(",
4321 "while ",
4322 "while(",
4323 "switch ",
4324 "switch(",
4325 "try ",
4326 "try{",
4327 "const ",
4328 "let ",
4329 "var ",
4330 "function ",
4331 "function(",
4332 "function*",
4333 "class ",
4334 "throw ",
4335 "do ",
4336 "do{",
4337 "{",
4338 "async function",
4339 "debugger",
4340];
4341
4342#[derive(PartialEq, Clone, Copy)]
4344enum ScanState {
4345 Code,
4346 SingleQuote,
4347 DoubleQuote,
4348 Template,
4349}
4350
4351fn should_prepend_return(code: &str) -> bool {
4362 use ScanState::{Code, DoubleQuote, SingleQuote, Template};
4363
4364 let code = code.trim();
4365 if code.is_empty() {
4366 return false;
4367 }
4368
4369 if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
4370 return false;
4371 }
4372
4373 let bytes = code.as_bytes();
4374 let mut i = 0;
4375 let mut depth: i32 = 0;
4376 let mut state = ScanState::Code;
4377
4378 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
4379 let is_return_token = |i: usize| -> bool {
4381 let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
4382 prev_ok
4383 && code[i..].starts_with("return")
4384 && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
4385 };
4386
4387 while i < bytes.len() {
4388 let c = bytes[i];
4389 match state {
4390 Code => match c {
4391 b'\'' => state = SingleQuote,
4392 b'"' => state = DoubleQuote,
4393 b'`' => state = Template,
4394 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
4395 while i < bytes.len() && bytes[i] != b'\n' {
4396 i += 1;
4397 }
4398 continue;
4399 }
4400 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4401 i += 2;
4402 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
4403 i += 1;
4404 }
4405 i += 2;
4406 continue;
4407 }
4408 b'(' | b'[' | b'{' => depth += 1,
4409 b')' | b']' | b'}' => depth -= 1,
4410 b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
4412 b'r' if depth <= 0 && is_return_token(i) => return false,
4414 _ => {}
4415 },
4416 SingleQuote => {
4417 if c == b'\\' {
4418 i += 1;
4419 } else if c == b'\'' {
4420 state = Code;
4421 }
4422 }
4423 DoubleQuote => {
4424 if c == b'\\' {
4425 i += 1;
4426 } else if c == b'"' {
4427 state = Code;
4428 }
4429 }
4430 Template => {
4431 if c == b'\\' {
4432 i += 1;
4433 } else if c == b'`' {
4434 state = Code;
4435 }
4436 }
4437 }
4438 i += 1;
4439 }
4440
4441 true
4442}
4443
4444#[cfg(test)]
4445mod prop_tests {
4446 use super::should_prepend_return;
4451 use proptest::prelude::*;
4452
4453 fn ident() -> impl Strategy<Value = String> {
4455 prop_oneof![
4456 Just("a".to_string()),
4457 Just("x".to_string()),
4458 Just("foo".to_string()),
4459 Just("window.x".to_string()),
4460 Just("document.title".to_string()),
4461 Just("obj.prop".to_string()),
4462 Just("arr[0]".to_string()),
4463 Just("localStorage".to_string()),
4464 ]
4465 }
4466
4467 fn bare_expr() -> impl Strategy<Value = String> {
4470 prop_oneof![
4471 ident(),
4472 (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
4473 (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
4474 ident().prop_map(|a| format!("{a}.length")),
4475 any::<u16>().prop_map(|n| n.to_string()),
4476 ]
4477 }
4478
4479 proptest! {
4480 #[test]
4483 fn never_panics_on_arbitrary_input(s in ".{0,256}") {
4484 let _ = should_prepend_return(&s);
4485 }
4486
4487 #[test]
4489 fn bare_expressions_are_prepended(e in bare_expr()) {
4490 prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
4491 }
4492
4493 #[test]
4496 fn semicolon_multistatement_with_return_never_prepended(
4497 setup in bare_expr(), ret in bare_expr()
4498 ) {
4499 let code = format!("{setup}; return {ret}");
4500 prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
4501 }
4502
4503 #[test]
4505 fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
4506 let code = format!("{pre}\nreturn {ret}");
4507 prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
4508 }
4509
4510 #[test]
4513 fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
4514 let code = format!("'do;not;split return {inner}'");
4516 prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
4517 }
4518 }
4519}
4520
4521#[cfg(test)]
4522mod tests {
4523 use super::*;
4524
4525 #[test]
4526 fn env_filter_drops_secrets_keeps_safe() {
4527 assert!(is_safe_env_key("HOME"));
4529 assert!(is_safe_env_key("LANG"));
4530 assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
4531 assert!(is_safe_env_key("VICTAURI_PORT"));
4532 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
4534 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
4535 assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
4536 assert!(!is_safe_env_key("VICTAURI_API_KEY"));
4537 assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
4539 assert!(!is_safe_env_key("RANDOM_VAR"));
4540 assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
4543 assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
4546 assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
4547 assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
4548 assert!(!is_safe_env_key("VICTAURI_JWT"));
4549 assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
4550 }
4551
4552 #[test]
4553 fn prepend_return_bare_expressions() {
4554 assert!(should_prepend_return("document.title"));
4555 assert!(should_prepend_return("5 + 5"));
4556 assert!(should_prepend_return("\"justexpr\""));
4557 assert!(should_prepend_return("await fetch('/x')"));
4558 assert!(should_prepend_return(
4559 "document.querySelectorAll('a').length"
4560 ));
4561 assert!(should_prepend_return("x ? a : b"));
4562 assert!(should_prepend_return("document.title;"));
4564 assert!(should_prepend_return("'a;b;c'"));
4566 assert!(should_prepend_return("\"x;y\".length"));
4567 assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
4569 }
4570
4571 #[test]
4572 fn no_prepend_for_statement_blocks() {
4573 assert!(!should_prepend_return(
4575 "localStorage.setItem('k','v'); return localStorage.getItem('k')"
4576 ));
4577 assert!(!should_prepend_return(
4578 "window.scrollTo(0,50); return window.scrollY"
4579 ));
4580 assert!(!should_prepend_return("console.log('x'); return 123"));
4581 assert!(!should_prepend_return("window.__z=7; return 'ok'"));
4582 assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
4584 }
4585
4586 #[test]
4587 fn no_prepend_for_statement_keywords() {
4588 assert!(!should_prepend_return("return 42"));
4589 assert!(!should_prepend_return("const x = 1; return x"));
4590 assert!(!should_prepend_return("let y = 2"));
4591 assert!(!should_prepend_return("var z = 3"));
4592 assert!(!should_prepend_return("if (x) { return 1 }"));
4593 assert!(!should_prepend_return("for (const x of y) doThing(x)"));
4594 assert!(!should_prepend_return("throw new Error('x')"));
4595 assert!(!should_prepend_return("function f(){}"));
4596 assert!(!should_prepend_return("{ a: 1 }")); }
4598
4599 #[test]
4600 fn empty_code_no_prepend() {
4601 assert!(!should_prepend_return(""));
4602 assert!(!should_prepend_return(" "));
4603 }
4604
4605 #[test]
4606 fn envelope_unwrap_value() {
4607 assert_eq!(
4608 unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
4609 Ok("\"4DA\"".to_string())
4610 );
4611 assert_eq!(
4612 unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
4613 Ok("42".to_string())
4614 );
4615 }
4616
4617 #[test]
4618 fn envelope_unwrap_undefined_null() {
4619 assert_eq!(
4620 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
4621 Ok("undefined".to_string())
4622 );
4623 assert_eq!(
4624 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
4625 Ok("null".to_string())
4626 );
4627 }
4628
4629 #[test]
4630 fn envelope_unwrap_error() {
4631 let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
4632 assert!(r.unwrap_err().contains("boom"));
4633 }
4634
4635 #[test]
4636 fn envelope_unwrap_deeply_nested_does_not_leak() {
4637 let mut value = String::from("0");
4641 for _ in 0..300 {
4642 value = format!("{{\"n\":{value}}}");
4643 }
4644 let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
4645 let out = unwrap_eval_envelope(raw).unwrap();
4646 assert!(
4647 out.starts_with(r#"{"n":"#),
4648 "deep value should be unwrapped, got: {}",
4649 &out[..out.len().min(40)]
4650 );
4651 assert!(
4652 !out.contains("__victauri_ok"),
4653 "envelope must not leak into the result"
4654 );
4655 }
4656
4657 #[test]
4658 fn js_string_simple() {
4659 assert_eq!(js_string("hello"), "\"hello\"");
4660 }
4661
4662 #[test]
4663 fn js_string_single_quotes() {
4664 let result = js_string("it's a test");
4665 assert!(result.contains("it's a test"));
4666 }
4667
4668 #[test]
4669 fn js_string_double_quotes() {
4670 let result = js_string(r#"say "hello""#);
4671 assert!(result.contains(r#"\""#));
4672 }
4673
4674 #[test]
4675 fn js_string_backslashes() {
4676 let result = js_string(r"path\to\file");
4677 assert!(result.contains(r"\\"));
4678 }
4679
4680 #[test]
4681 fn js_string_newlines_and_tabs() {
4682 let result = js_string("line1\nline2\ttab");
4683 assert!(result.contains(r"\n"));
4684 assert!(result.contains(r"\t"));
4685 assert!(!result.contains('\n'));
4686 }
4687
4688 #[test]
4689 fn js_string_null_bytes() {
4690 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
4691 let result = js_string(&input);
4692 assert!(result.contains("\\u0000"));
4694 assert!(!result.contains('\0'));
4695 }
4696
4697 #[test]
4698 fn js_string_template_literal_injection() {
4699 let result = js_string("`${alert(1)}`");
4700 assert!(result.starts_with('"'));
4703 assert!(result.ends_with('"'));
4704 }
4705
4706 #[test]
4707 fn js_string_unicode_separators() {
4708 let result = js_string("a\u{2028}b\u{2029}c");
4713 let decoded: String = serde_json::from_str(&result).unwrap();
4715 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
4716 }
4717
4718 #[test]
4719 fn js_string_empty() {
4720 assert_eq!(js_string(""), "\"\"");
4721 }
4722
4723 #[test]
4724 fn js_string_html_script_close() {
4725 let result = js_string("</script><img onerror=alert(1)>");
4727 assert!(result.starts_with('"'));
4728 let decoded: String = serde_json::from_str(&result).unwrap();
4730 assert_eq!(decoded, "</script><img onerror=alert(1)>");
4731 }
4732
4733 #[test]
4734 fn js_string_very_long() {
4735 let long = "a".repeat(100_000);
4736 let result = js_string(&long);
4737 assert!(result.len() >= 100_002); }
4739
4740 #[test]
4743 fn url_allows_http() {
4744 assert!(validate_url("http://example.com", false).is_ok());
4745 }
4746
4747 #[test]
4748 fn url_allows_https() {
4749 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
4750 }
4751
4752 #[test]
4753 fn url_allows_http_localhost() {
4754 assert!(validate_url("http://localhost:3000", false).is_ok());
4755 }
4756
4757 #[test]
4758 fn url_blocks_file_by_default() {
4759 let err = validate_url("file:///etc/passwd", false).unwrap_err();
4760 assert!(err.contains("file"), "error should mention the file scheme");
4761 }
4762
4763 #[test]
4764 fn url_allows_file_when_opted_in() {
4765 assert!(validate_url("file:///tmp/test.html", true).is_ok());
4766 }
4767
4768 #[test]
4769 fn url_blocks_javascript() {
4770 assert!(validate_url("javascript:alert(1)", false).is_err());
4771 }
4772
4773 #[test]
4774 fn url_blocks_javascript_case_insensitive() {
4775 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
4776 }
4777
4778 #[test]
4779 fn url_blocks_data_scheme() {
4780 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
4781 }
4782
4783 #[test]
4784 fn url_blocks_vbscript() {
4785 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
4786 }
4787
4788 #[test]
4789 fn url_rejects_invalid() {
4790 assert!(validate_url("not a url at all", false).is_err());
4791 }
4792
4793 #[test]
4794 fn url_strips_control_chars() {
4795 let input = format!("http://example{}com", '\0');
4797 assert!(validate_url(&input, false).is_ok());
4798 }
4799
4800 #[test]
4803 fn css_color_valid_hex() {
4804 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
4805 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
4806 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
4807 }
4808
4809 #[test]
4810 fn css_color_valid_rgb() {
4811 assert_eq!(
4812 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
4813 "rgb(255, 0, 0)"
4814 );
4815 assert_eq!(
4816 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
4817 "rgba(0, 0, 0, 0.5)"
4818 );
4819 }
4820
4821 #[test]
4822 fn css_color_valid_named() {
4823 assert_eq!(sanitize_css_color("red").unwrap(), "red");
4824 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
4825 }
4826
4827 #[test]
4828 fn css_color_valid_hsl() {
4829 assert_eq!(
4830 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
4831 "hsl(120, 50%, 50%)"
4832 );
4833 }
4834
4835 #[test]
4836 fn css_color_rejects_too_long() {
4837 let long = "a".repeat(101);
4838 assert!(sanitize_css_color(&long).is_err());
4839 }
4840
4841 #[test]
4842 fn css_color_rejects_backslash_escapes() {
4843 assert!(sanitize_css_color(r"red\00").is_err());
4844 assert!(sanitize_css_color(r"\72\65\64").is_err());
4845 }
4846
4847 #[test]
4848 fn css_color_rejects_url_injection() {
4849 assert!(sanitize_css_color("url(http://evil.com)").is_err());
4850 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
4851 }
4852
4853 #[test]
4854 fn css_color_rejects_expression_injection() {
4855 assert!(sanitize_css_color("expression(alert(1))").is_err());
4856 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
4857 }
4858
4859 #[test]
4860 fn css_color_rejects_import() {
4861 assert!(sanitize_css_color("@import url(evil.css)").is_err());
4862 }
4863
4864 #[test]
4865 fn css_color_rejects_semicolons_and_braces() {
4866 assert!(sanitize_css_color("red; background: url(evil)").is_err());
4867 assert!(sanitize_css_color("red} body { color: blue").is_err());
4868 }
4869
4870 #[test]
4871 fn css_color_rejects_special_chars() {
4872 assert!(sanitize_css_color("red<script>").is_err());
4873 assert!(sanitize_css_color("red\"onload=alert").is_err());
4874 assert!(sanitize_css_color("red'onclick=alert").is_err());
4875 }
4876
4877 #[test]
4878 fn css_color_trims_whitespace() {
4879 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
4880 }
4881
4882 #[test]
4883 fn css_color_empty_string() {
4884 assert_eq!(sanitize_css_color("").unwrap(), "");
4885 }
4886}