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