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