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 from the JS-side IPC interception log.",
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.",
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::Manage => {
1354 if !self.state.privacy.is_tool_enabled("window.manage") {
1355 return tool_disabled("window.manage");
1356 }
1357 let Some(manage_action) = ¶ms.manage_action else {
1358 return missing_param("manage_action", "manage");
1359 };
1360 match self
1361 .bridge
1362 .manage_window(params.label.as_deref(), manage_action.as_str())
1363 {
1364 Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1365 Err(e) => tool_error(e),
1366 }
1367 }
1368 WindowAction::Resize => {
1369 if !self.state.privacy.is_tool_enabled("window.resize") {
1370 return tool_disabled("window.resize");
1371 }
1372 let Some(width) = params.width else {
1373 return missing_param("width", "resize");
1374 };
1375 let Some(height) = params.height else {
1376 return missing_param("height", "resize");
1377 };
1378 if width == 0 || height == 0 {
1379 return tool_error_with_hint(
1380 format!(
1381 "invalid window size {width}x{height}: width and height must be > 0"
1382 ),
1383 RecoveryHint::CheckInput,
1384 );
1385 }
1386 match self
1387 .bridge
1388 .resize_window(params.label.as_deref(), width, height)
1389 {
1390 Ok(()) => {
1391 let result =
1392 serde_json::json!({"ok": true, "width": width, "height": height});
1393 CallToolResult::success(vec![Content::text(result.to_string())])
1394 }
1395 Err(e) => tool_error(e),
1396 }
1397 }
1398 WindowAction::MoveTo => {
1399 if !self.state.privacy.is_tool_enabled("window.move_to") {
1400 return tool_disabled("window.move_to");
1401 }
1402 let Some(x) = params.x else {
1403 return missing_param("x", "move_to");
1404 };
1405 let Some(y) = params.y else {
1406 return missing_param("y", "move_to");
1407 };
1408 match self.bridge.move_window(params.label.as_deref(), x, y) {
1409 Ok(()) => {
1410 let result = serde_json::json!({"ok": true, "x": x, "y": y});
1411 CallToolResult::success(vec![Content::text(result.to_string())])
1412 }
1413 Err(e) => tool_error(e),
1414 }
1415 }
1416 WindowAction::SetTitle => {
1417 if !self.state.privacy.is_tool_enabled("window.set_title") {
1418 return tool_disabled("window.set_title");
1419 }
1420 let Some(title) = ¶ms.title else {
1421 return missing_param("title", "set_title");
1422 };
1423 match self.bridge.set_window_title(params.label.as_deref(), title) {
1424 Ok(()) => {
1425 let result = serde_json::json!({"ok": true, "title": title});
1426 CallToolResult::success(vec![Content::text(result.to_string())])
1427 }
1428 Err(e) => tool_error(e),
1429 }
1430 }
1431 }
1432 }
1433
1434 #[tool(
1435 description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1436 annotations(
1437 read_only_hint = false,
1438 destructive_hint = true,
1439 idempotent_hint = false,
1440 open_world_hint = false
1441 )
1442 )]
1443 async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1444 match params.action {
1445 StorageAction::Get => {
1446 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1447 StorageType::Session => "getSessionStorage",
1448 StorageType::Local => "getLocalStorage",
1449 };
1450 let key_arg = params
1451 .key
1452 .as_ref()
1453 .map(|k| js_string(k))
1454 .unwrap_or_default();
1455 let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1456 self.eval_bridge(&code, params.webview_label.as_deref())
1457 .await
1458 }
1459 StorageAction::Set => {
1460 if !self.state.privacy.is_tool_enabled("set_storage") {
1461 return tool_disabled("set_storage");
1462 }
1463 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1464 StorageType::Session => "setSessionStorage",
1465 StorageType::Local => "setLocalStorage",
1466 };
1467 let Some(key) = ¶ms.key else {
1468 return missing_param("key", "set");
1469 };
1470 let value = params
1471 .value
1472 .as_ref()
1473 .cloned()
1474 .unwrap_or(serde_json::Value::Null);
1475 let value_json =
1476 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1477 let code = format!(
1478 "return window.__VICTAURI__?.{method}({}, {value_json})",
1479 js_string(key)
1480 );
1481 self.eval_bridge(&code, params.webview_label.as_deref())
1482 .await
1483 }
1484 StorageAction::Delete => {
1485 if !self.state.privacy.is_tool_enabled("delete_storage") {
1486 return tool_disabled("delete_storage");
1487 }
1488 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1489 StorageType::Session => "deleteSessionStorage",
1490 StorageType::Local => "deleteLocalStorage",
1491 };
1492 let Some(key) = ¶ms.key else {
1493 return missing_param("key", "delete");
1494 };
1495 let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1496 self.eval_bridge(&code, params.webview_label.as_deref())
1497 .await
1498 }
1499 StorageAction::GetCookies => {
1500 self.eval_bridge(
1501 "return window.__VICTAURI__?.getCookies()",
1502 params.webview_label.as_deref(),
1503 )
1504 .await
1505 }
1506 }
1507 }
1508
1509 #[tool(
1510 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.",
1511 annotations(
1512 read_only_hint = false,
1513 destructive_hint = false,
1514 idempotent_hint = false,
1515 open_world_hint = false
1516 )
1517 )]
1518 async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1519 match params.action {
1520 NavigateAction::GoTo => {
1521 if !self.state.privacy.is_tool_enabled("navigate") {
1522 return tool_disabled("navigate");
1523 }
1524 let Some(url) = ¶ms.url else {
1525 return missing_param("url", "go_to");
1526 };
1527 if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1528 return tool_error(e);
1529 }
1530 let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1531 self.eval_bridge(&code, params.webview_label.as_deref())
1532 .await
1533 }
1534 NavigateAction::GoBack => {
1535 self.eval_bridge(
1536 "return window.__VICTAURI__?.navigateBack()",
1537 params.webview_label.as_deref(),
1538 )
1539 .await
1540 }
1541 NavigateAction::GetHistory => {
1542 self.eval_bridge(
1543 "return window.__VICTAURI__?.getNavigationLog()",
1544 params.webview_label.as_deref(),
1545 )
1546 .await
1547 }
1548 NavigateAction::SetDialogResponse => {
1549 if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1550 return tool_disabled("set_dialog_response");
1551 }
1552 let Some(dialog_type) = params.dialog_type else {
1553 return missing_param("dialog_type", "set_dialog_response");
1554 };
1555 let Some(dialog_action) = params.dialog_action else {
1556 return missing_param("dialog_action", "set_dialog_response");
1557 };
1558 let text_arg = params
1559 .text
1560 .as_ref()
1561 .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1562 let code = format!(
1563 "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1564 js_string(dialog_type.as_str()),
1565 js_string(dialog_action.as_str())
1566 );
1567 self.eval_bridge(&code, params.webview_label.as_deref())
1568 .await
1569 }
1570 NavigateAction::GetDialogLog => {
1571 self.eval_bridge(
1572 "return window.__VICTAURI__?.getDialogLog()",
1573 params.webview_label.as_deref(),
1574 )
1575 .await
1576 }
1577 }
1578 }
1579
1580 #[tool(
1581 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).",
1582 annotations(
1583 read_only_hint = false,
1584 destructive_hint = false,
1585 idempotent_hint = false,
1586 open_world_hint = false
1587 )
1588 )]
1589 async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1590 const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1591 self.track_tool_call();
1592 if !self.state.privacy.is_tool_enabled("recording") {
1593 return tool_disabled("recording");
1594 }
1595 match params.action {
1596 RecordingAction::Start => {
1597 let session_id = params
1598 .session_id
1599 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1600 match self.state.recorder.start(session_id.clone()) {
1601 Ok(()) => {
1602 let result = serde_json::json!({
1603 "started": true,
1604 "session_id": session_id,
1605 });
1606 CallToolResult::success(vec![Content::text(result.to_string())])
1607 }
1608 Err(e) => tool_error(e.to_string()),
1609 }
1610 }
1611 RecordingAction::Stop => match self.state.recorder.stop() {
1612 Some(session) => json_result(&session),
1613 None => tool_error("no recording is active"),
1614 },
1615 RecordingAction::Checkpoint => {
1616 let Some(id) = params.checkpoint_id else {
1617 return missing_param("checkpoint_id", "checkpoint");
1618 };
1619 let state = params.state.unwrap_or(serde_json::Value::Null);
1620 match self
1621 .state
1622 .recorder
1623 .checkpoint(id.clone(), params.checkpoint_label, state)
1624 {
1625 Ok(()) => {
1626 let result = serde_json::json!({
1627 "created": true,
1628 "checkpoint_id": id,
1629 "event_index": self.state.recorder.event_count(),
1630 });
1631 CallToolResult::success(vec![Content::text(result.to_string())])
1632 }
1633 Err(e) => tool_error(e.to_string()),
1634 }
1635 }
1636 RecordingAction::ListCheckpoints => {
1637 let checkpoints = self.state.recorder.get_checkpoints();
1638 json_result(&checkpoints)
1639 }
1640 RecordingAction::GetEvents => {
1641 let events = self
1642 .state
1643 .recorder
1644 .events_since(params.since_index.unwrap_or(0));
1645 json_result(&events)
1646 }
1647 RecordingAction::EventsBetween => {
1648 let Some(from) = ¶ms.from else {
1649 return missing_param("from", "events_between");
1650 };
1651 let Some(to) = ¶ms.to else {
1652 return missing_param("to", "events_between");
1653 };
1654 match self.state.recorder.events_between_checkpoints(from, to) {
1655 Ok(events) => json_result(&events),
1656 Err(e) => tool_error(e.to_string()),
1657 }
1658 }
1659 RecordingAction::GetReplay => {
1660 let calls = self.state.recorder.ipc_replay_sequence();
1661 json_result(&calls)
1662 }
1663 RecordingAction::Export => match self.state.recorder.export() {
1664 Some(s) => {
1665 let json = serde_json::to_string_pretty(&s)
1666 .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1667 CallToolResult::success(vec![Content::text(json)])
1668 }
1669 None => tool_error("no recording is active — start one first"),
1670 },
1671 RecordingAction::Import => {
1672 let Some(session_json) = ¶ms.session_json else {
1673 return missing_param("session_json", "import");
1674 };
1675 if session_json.len() > MAX_SESSION_JSON {
1676 return tool_error("session JSON exceeds maximum size (10 MB)");
1677 }
1678 let session: victauri_core::RecordedSession =
1679 match serde_json::from_str(session_json) {
1680 Ok(s) => s,
1681 Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1682 };
1683
1684 let result = serde_json::json!({
1685 "imported": true,
1686 "session_id": session.id,
1687 "event_count": session.events.len(),
1688 "checkpoint_count": session.checkpoints.len(),
1689 "started_at": session.started_at.to_rfc3339(),
1690 });
1691 self.state.recorder.import(session);
1692 CallToolResult::success(vec![Content::text(result.to_string())])
1693 }
1694 RecordingAction::Flush => {
1695 if !self.state.recorder.is_recording() {
1696 return tool_error("no active recording — start a recording first");
1697 }
1698 let code = "return window.__VICTAURI__?.getEventStream(0)";
1699 match self
1700 .eval_with_return(code, params.webview_label.as_deref())
1701 .await
1702 {
1703 Ok(result_str) => {
1704 let events: Vec<serde_json::Value> =
1705 serde_json::from_str(&result_str).unwrap_or_default();
1706 let mut count = 0u64;
1707 for ev in &events {
1708 if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
1709 self.state.event_log.push(app_event.clone());
1710 self.state.recorder.record_event(app_event);
1711 count += 1;
1712 }
1713 }
1714 json_result(&serde_json::json!({
1715 "flushed": true,
1716 "events_captured": count,
1717 }))
1718 }
1719 Err(e) => tool_error(format!("flush failed: {e}")),
1720 }
1721 }
1722 RecordingAction::Replay => {
1723 let calls = self.state.recorder.ipc_replay_sequence();
1724 if calls.is_empty() {
1725 return tool_error("no IPC calls recorded — record a session first");
1726 }
1727 let mut replay_results = Vec::new();
1728 for call in &calls {
1729 let code = format!(
1730 "return window.__TAURI_INTERNALS__.invoke({})",
1731 js_string(&call.command)
1732 );
1733 let outcome = match self
1734 .eval_with_return(&code, params.webview_label.as_deref())
1735 .await
1736 {
1737 Ok(result_str) => {
1738 let value: serde_json::Value = serde_json::from_str(&result_str)
1739 .unwrap_or(serde_json::Value::String(result_str));
1740 let shape = crate::introspection::JsonShape::from_value(&value);
1741 serde_json::json!({
1742 "command": call.command,
1743 "status": "ok",
1744 "response_type": shape.type_name(),
1745 })
1746 }
1747 Err(e) => {
1748 serde_json::json!({
1749 "command": call.command,
1750 "status": "error",
1751 "error": e,
1752 })
1753 }
1754 };
1755 replay_results.push(outcome);
1756 }
1757 let passed = replay_results
1758 .iter()
1759 .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
1760 .count();
1761 let result = serde_json::json!({
1762 "replayed": replay_results.len(),
1763 "passed": passed,
1764 "failed": replay_results.len() - passed,
1765 "results": replay_results,
1766 });
1767 json_result(&result)
1768 }
1769 }
1770 }
1771
1772 #[tool(
1773 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).",
1774 annotations(
1775 read_only_hint = true,
1776 destructive_hint = false,
1777 idempotent_hint = true,
1778 open_world_hint = false
1779 )
1780 )]
1781 async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1782 match params.action {
1783 InspectAction::GetStyles => {
1784 let Some(ref_id) = ¶ms.ref_id else {
1785 return missing_param("ref_id", "get_styles");
1786 };
1787 let props_arg = match ¶ms.properties {
1788 Some(props) => {
1789 let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1790 format!("[{}]", arr.join(","))
1791 }
1792 None => "null".to_string(),
1793 };
1794 let code = format!(
1795 "return window.__VICTAURI__?.getStyles({}, {})",
1796 js_string(ref_id),
1797 props_arg
1798 );
1799 self.eval_bridge(&code, params.webview_label.as_deref())
1800 .await
1801 }
1802 InspectAction::GetBoundingBoxes => {
1803 let Some(ref_ids) = ¶ms.ref_ids else {
1804 return missing_param("ref_ids", "get_bounding_boxes");
1805 };
1806 let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1807 let code = format!(
1808 "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1809 refs.join(",")
1810 );
1811 self.eval_bridge(&code, params.webview_label.as_deref())
1812 .await
1813 }
1814 InspectAction::Highlight => {
1815 let Some(ref_id) = ¶ms.ref_id else {
1816 return missing_param("ref_id", "highlight");
1817 };
1818 let color_arg = match ¶ms.color {
1819 Some(c) => match sanitize_css_color(c) {
1820 Ok(safe) => format!("\"{safe}\""),
1821 Err(e) => return tool_error(e),
1822 },
1823 None => "null".to_string(),
1824 };
1825 let label_arg = match ¶ms.label {
1826 Some(l) => js_string(l),
1827 None => "null".to_string(),
1828 };
1829 let code = format!(
1830 "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1831 js_string(ref_id),
1832 color_arg,
1833 label_arg
1834 );
1835 self.eval_bridge(&code, params.webview_label.as_deref())
1836 .await
1837 }
1838 InspectAction::ClearHighlights => {
1839 self.eval_bridge(
1840 "return window.__VICTAURI__?.clearHighlights()",
1841 params.webview_label.as_deref(),
1842 )
1843 .await
1844 }
1845 InspectAction::AuditAccessibility => {
1846 self.eval_bridge(
1847 "return window.__VICTAURI__?.auditAccessibility()",
1848 params.webview_label.as_deref(),
1849 )
1850 .await
1851 }
1852 InspectAction::GetPerformance => {
1853 self.eval_bridge(
1854 "return window.__VICTAURI__?.getPerformanceMetrics()",
1855 params.webview_label.as_deref(),
1856 )
1857 .await
1858 }
1859 }
1860 }
1861
1862 #[tool(
1863 description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1864 annotations(
1865 read_only_hint = false,
1866 destructive_hint = false,
1867 idempotent_hint = true,
1868 open_world_hint = false
1869 )
1870 )]
1871 async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1872 match params.action {
1873 CssAction::Inject => {
1874 if !self.state.privacy.is_tool_enabled("inject_css") {
1875 return tool_disabled("inject_css");
1876 }
1877 let Some(css) = ¶ms.css else {
1878 return missing_param("css", "inject");
1879 };
1880 let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1881 self.eval_bridge(&code, params.webview_label.as_deref())
1882 .await
1883 }
1884 CssAction::Remove => {
1885 if !self.state.privacy.is_tool_enabled("css.remove") {
1886 return tool_disabled("css.remove");
1887 }
1888 self.eval_bridge(
1889 "return window.__VICTAURI__?.removeInjectedCss()",
1890 params.webview_label.as_deref(),
1891 )
1892 .await
1893 }
1894 }
1895 }
1896
1897 #[tool(
1898 description = "Network request interception (Playwright route() equivalent, no CDP). \
1899 Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
1900 Actions:\n\
1901 - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
1902 and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
1903 mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
1904 `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
1905 - `list`: list active rules.\n\
1906 - `clear` (by `id`) / `clear_all`: remove rules.\n\
1907 - `matches`: log of intercepted requests.\n\
1908 Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
1909 Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
1910 For Tauri IPC-layer faults, prefer the `fault` tool.",
1911 annotations(
1912 read_only_hint = false,
1913 destructive_hint = false,
1914 idempotent_hint = false,
1915 open_world_hint = false
1916 )
1917 )]
1918 async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
1919 self.track_tool_call();
1920 match params.action {
1921 RouteAction::Add => {
1922 if !self.state.privacy.is_tool_enabled("route.add") {
1923 return tool_disabled("route.add");
1924 }
1925 let Some(pattern) = ¶ms.pattern else {
1926 return missing_param("pattern", "add");
1927 };
1928 let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
1929 let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
1930 let mut rule = serde_json::json!({
1931 "pattern": pattern,
1932 "match_type": match_type.as_str(),
1933 "action": behavior.as_str(),
1934 });
1935 if let Some(m) = ¶ms.method {
1936 rule["method"] = serde_json::json!(m);
1937 }
1938 if let Some(s) = params.status {
1939 rule["status"] = serde_json::json!(s);
1940 }
1941 if let Some(st) = ¶ms.status_text {
1942 rule["status_text"] = serde_json::json!(st);
1943 }
1944 if let Some(h) = ¶ms.headers {
1945 rule["headers"] = h.clone();
1946 }
1947 if let Some(b) = ¶ms.body {
1948 rule["body"] = match b {
1951 serde_json::Value::String(s) => serde_json::json!(s),
1952 other => serde_json::json!(other.to_string()),
1953 };
1954 }
1955 if let Some(ct) = ¶ms.content_type {
1956 rule["content_type"] = serde_json::json!(ct);
1957 }
1958 if let Some(d) = params.delay_ms {
1959 rule["delay_ms"] = serde_json::json!(d);
1960 }
1961 if let Some(t) = params.times {
1962 rule["times"] = serde_json::json!(t);
1963 }
1964 let code = format!(
1965 "return window.__VICTAURI__?.addRoute({})",
1966 js_string(&rule.to_string())
1967 );
1968 self.eval_bridge(&code, params.webview_label.as_deref())
1969 .await
1970 }
1971 RouteAction::List => {
1972 self.eval_bridge(
1973 "return window.__VICTAURI__?.getRouteRules()",
1974 params.webview_label.as_deref(),
1975 )
1976 .await
1977 }
1978 RouteAction::Clear => {
1979 let Some(id) = params.id else {
1980 return missing_param("id", "clear");
1981 };
1982 let code = format!("return window.__VICTAURI__?.clearRoute({id})");
1983 self.eval_bridge(&code, params.webview_label.as_deref())
1984 .await
1985 }
1986 RouteAction::ClearAll => {
1987 self.eval_bridge(
1988 "return window.__VICTAURI__?.clearRoutes()",
1989 params.webview_label.as_deref(),
1990 )
1991 .await
1992 }
1993 RouteAction::Matches => {
1994 let limit = params.limit.unwrap_or(100);
1995 let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
1996 self.eval_bridge(&code, params.webview_label.as_deref())
1997 .await
1998 }
1999 }
2000 }
2001
2002 #[tool(
2003 description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2004 into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2005 `logs` (network/console). Actions:\n\
2006 - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2007 `with_events=true` to also start the event recorder.\n\
2008 - `stop`: stop and return a summary (frame count, duration, timestamps).\n\
2009 - `status`: active flag + buffered frame count.\n\
2010 - `frames`: return captured frames as base64 PNGs (`limit` caps how many).",
2011 annotations(
2012 read_only_hint = false,
2013 destructive_hint = false,
2014 idempotent_hint = false,
2015 open_world_hint = false
2016 )
2017 )]
2018 async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2019 self.track_tool_call();
2020 if !self.state.privacy.is_tool_enabled("trace")
2021 || !self.state.privacy.is_tool_enabled("screenshot")
2022 {
2023 return tool_disabled("trace");
2024 }
2025 match params.action {
2026 TraceAction::Start => {
2027 let interval = params.interval_ms.unwrap_or(500);
2028 let max_frames = params.max_frames.unwrap_or(60);
2029 let label = params.webview_label.clone();
2030 let generation = self
2031 .state
2032 .screencast
2033 .start(interval, max_frames, label.clone());
2034
2035 let mut events_started = false;
2036 if params.with_events.unwrap_or(false) {
2037 let session_id = uuid::Uuid::new_v4().to_string();
2038 if self.state.recorder.start(session_id).is_ok() {
2039 events_started = true;
2040 }
2041 }
2042
2043 let bridge = self.bridge.clone();
2046 let screencast = self.state.screencast.clone();
2047 tokio::spawn(async move {
2048 let t0 = std::time::Instant::now();
2049 while screencast.is_active() && screencast.generation() == generation {
2050 if let Ok(handle) = bridge.get_native_handle(label.as_deref())
2051 && let Ok(png) = crate::screenshot::capture_window(handle).await
2052 {
2053 use base64::Engine;
2054 let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2055 #[allow(clippy::cast_possible_truncation)]
2056 screencast.push_frame(t0.elapsed().as_millis() as u64, b64);
2057 }
2058 tokio::time::sleep(std::time::Duration::from_millis(
2059 screencast.interval_ms(),
2060 ))
2061 .await;
2062 }
2063 });
2064
2065 json_result(&serde_json::json!({
2066 "started": true,
2067 "interval_ms": interval.max(50),
2068 "max_frames": max_frames.clamp(1, 600),
2069 "with_events": events_started,
2070 }))
2071 }
2072 TraceAction::Stop => {
2073 let frame_count = self.state.screencast.stop();
2074 let timestamps = self.state.screencast.frame_timestamps();
2075 let duration_ms = timestamps.last().copied().unwrap_or(0);
2076 let event_count = self.state.recorder.event_count();
2077 json_result(&serde_json::json!({
2078 "stopped": true,
2079 "frame_count": frame_count,
2080 "duration_ms": duration_ms,
2081 "frame_timestamps_ms": timestamps,
2082 "recorded_event_count": event_count,
2083 "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2084 }))
2085 }
2086 TraceAction::Status => json_result(&serde_json::json!({
2087 "active": self.state.screencast.is_active(),
2088 "frame_count": self.state.screencast.frame_count(),
2089 "interval_ms": self.state.screencast.interval_ms(),
2090 })),
2091 TraceAction::Frames => {
2092 let limit = params.limit.unwrap_or(0);
2093 let frames = self.state.screencast.frames(limit);
2094 let items: Vec<Content> = frames
2095 .into_iter()
2096 .map(|f| Content::image(f.data_b64, "image/png"))
2097 .collect();
2098 if items.is_empty() {
2099 return json_result(&serde_json::json!({ "frames": 0 }));
2100 }
2101 CallToolResult::success(items)
2102 }
2103 }
2104 }
2105
2106 #[tool(
2107 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).",
2108 annotations(
2109 read_only_hint = true,
2110 destructive_hint = false,
2111 idempotent_hint = true,
2112 open_world_hint = false
2113 )
2114 )]
2115 async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
2116 match params.action {
2117 LogsAction::Console => {
2118 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2119 let base = if since_arg.is_empty() {
2120 "window.__VICTAURI__?.getConsoleLogs()".to_string()
2121 } else {
2122 format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
2123 };
2124 let code = if let Some(limit) = params.limit {
2125 format!("return ({base} || []).slice(-{limit})")
2126 } else {
2127 format!("return {base}")
2128 };
2129 self.eval_bridge(&code, params.webview_label.as_deref())
2130 .await
2131 }
2132 LogsAction::Network => {
2133 let filter_arg = params
2134 .filter
2135 .as_ref()
2136 .map_or_else(|| "null".to_string(), |f| js_string(f));
2137 let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2138 let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
2139 let code = trimmed_log_js(&source, limit);
2140 self.eval_bridge(&code, params.webview_label.as_deref())
2141 .await
2142 }
2143 LogsAction::Ipc => {
2144 let wait = params.wait_for_capture.unwrap_or(false);
2145 let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2146 if wait {
2147 let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit);
2148 let code = format!(
2149 r"return (async function() {{
2150 await window.__VICTAURI__.waitForIpcComplete(500);
2151 return (function() {{ {inner} }})();
2152 }})()"
2153 );
2154 let timeout = std::time::Duration::from_millis(5000);
2155 match self
2156 .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
2157 .await
2158 {
2159 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2160 Err(e) => tool_error(e),
2161 }
2162 } else {
2163 let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit);
2164 self.eval_bridge(&code, params.webview_label.as_deref())
2165 .await
2166 }
2167 }
2168 LogsAction::Navigation => {
2169 let code = if let Some(limit) = params.limit {
2170 format!(
2171 "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
2172 )
2173 } else {
2174 "return window.__VICTAURI__?.getNavigationLog()".to_string()
2175 };
2176 self.eval_bridge(&code, params.webview_label.as_deref())
2177 .await
2178 }
2179 LogsAction::Dialogs => {
2180 let code = if let Some(limit) = params.limit {
2181 format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
2182 } else {
2183 "return window.__VICTAURI__?.getDialogLog()".to_string()
2184 };
2185 self.eval_bridge(&code, params.webview_label.as_deref())
2186 .await
2187 }
2188 LogsAction::Events => {
2189 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2190 let base = if since_arg.is_empty() {
2191 "window.__VICTAURI__?.getEventStream()".to_string()
2192 } else {
2193 format!("window.__VICTAURI__?.getEventStream({since_arg})")
2194 };
2195 let code = if let Some(limit) = params.limit {
2196 format!("return ({base} || []).slice(-{limit})")
2197 } else {
2198 format!("return {base}")
2199 };
2200 self.eval_bridge(&code, params.webview_label.as_deref())
2201 .await
2202 }
2203 LogsAction::SlowIpc => {
2204 let Some(threshold) = params.threshold_ms else {
2205 return missing_param("threshold_ms", "slow_ipc");
2206 };
2207 let limit = params.limit.unwrap_or(20);
2208 let mb = MAX_LOG_FIELD_BYTES;
2209 let code = format!(
2210 r"return (function() {{
2211 var MB = {mb};
2212 function trimField(v) {{
2213 if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
2214 if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }}
2215 return v;
2216 }}
2217 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; }}
2218 var log = window.__VICTAURI__?.getIpcLog() || [];
2219 var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
2220 slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
2221 return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }};
2222 }})()",
2223 );
2224 self.eval_bridge(&code, None).await
2225 }
2226 }
2227 }
2228
2229 #[tool(
2232 description = "Deep backend introspection — command profiling, IPC contract testing, \
2233 coverage, startup timing, capability auditing, database diagnostics, process \
2234 enumeration, and event bus monitoring. \
2235 These features exploit Victauri's position inside the Rust process.\n\n\
2236 Actions:\n\
2237 - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
2238 - `coverage`: Which registered commands have been called during this session.\n\
2239 - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
2240 - `contract_check`: Check all recorded contracts for schema drift.\n\
2241 - `contract_list`: List all recorded contract baselines.\n\
2242 - `contract_clear`: Clear all recorded contract baselines.\n\
2243 - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
2244 - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
2245 - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
2246 - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
2247 - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
2248 - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
2249 - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
2250 - `event_bus_clear`: Clear the event bus capture buffer.",
2251 annotations(
2252 read_only_hint = true,
2253 destructive_hint = false,
2254 idempotent_hint = true,
2255 open_world_hint = false
2256 )
2257 )]
2258 async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
2259 self.track_tool_call();
2260 if !self.state.privacy.is_tool_enabled("introspect") {
2261 return tool_disabled("introspect");
2262 }
2263
2264 match params.action {
2265 IntrospectAction::CommandTimings => {
2266 let mut stats = self.state.command_timings.all_stats();
2267 if let Some(threshold) = params.slow_threshold_ms {
2268 stats.retain(|s| s.avg_ms >= threshold);
2269 }
2270 let result = serde_json::json!({
2271 "commands": stats,
2272 "total_commands_profiled": self.state.command_timings.all_stats().len(),
2273 "slow_threshold_ms": params.slow_threshold_ms,
2274 });
2275 json_result(&result)
2276 }
2277 IntrospectAction::Coverage => {
2278 let registered: Vec<String> = self
2279 .state
2280 .registry
2281 .list()
2282 .iter()
2283 .map(|c| c.name.clone())
2284 .collect();
2285
2286 let code = "return window.__VICTAURI__?.getIpcLog()";
2287 let invoked: std::collections::HashSet<String> = match self
2288 .eval_with_return(code, params.webview_label.as_deref())
2289 .await
2290 {
2291 Ok(json_str) => {
2292 if let Ok(entries) =
2293 serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2294 {
2295 entries
2296 .iter()
2297 .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
2298 .map(String::from)
2299 .collect()
2300 } else {
2301 std::collections::HashSet::new()
2302 }
2303 }
2304 Err(_) => std::collections::HashSet::new(),
2305 };
2306
2307 let uncovered: Vec<&String> = registered
2308 .iter()
2309 .filter(|cmd| !invoked.contains(cmd.as_str()))
2310 .collect();
2311
2312 let coverage_pct = if registered.is_empty() {
2313 100.0
2314 } else {
2315 let covered = registered.len() - uncovered.len();
2316 (covered as f64 / registered.len() as f64) * 100.0
2317 };
2318
2319 let result = serde_json::json!({
2320 "registered_commands": registered.len(),
2321 "invoked_commands": invoked.len(),
2322 "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2323 "uncovered": uncovered,
2324 "invoked_not_registered": invoked.iter()
2325 .filter(|cmd| !registered.contains(cmd))
2326 .collect::<Vec<_>>(),
2327 });
2328 json_result(&result)
2329 }
2330 IntrospectAction::ContractRecord => {
2331 let Some(command) = params.command else {
2332 return missing_param("command", "contract_record");
2333 };
2334 let args_json = params.args.unwrap_or(serde_json::json!({}));
2335 let args_str =
2336 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
2337 let code = format!(
2338 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2339 js_string(&command)
2340 );
2341 match self
2342 .eval_with_return(&code, params.webview_label.as_deref())
2343 .await
2344 {
2345 Ok(result_str) => {
2346 let value: serde_json::Value = serde_json::from_str(&result_str)
2347 .unwrap_or(serde_json::Value::String(result_str.clone()));
2348 let shape = crate::introspection::JsonShape::from_value(&value);
2349 let sample = if result_str.len() > 4096 {
2350 format!("{}...(truncated)", &result_str[..4096])
2351 } else {
2352 result_str
2353 };
2354 let baseline = crate::introspection::ContractBaseline {
2355 command: command.clone(),
2356 args: args_json,
2357 shape: shape.clone(),
2358 sample,
2359 recorded_at: chrono_now(),
2360 };
2361 self.state.contract_store.record(baseline);
2362 let result = serde_json::json!({
2363 "recorded": true,
2364 "command": command,
2365 "shape_type": shape.type_name(),
2366 });
2367 json_result(&result)
2368 }
2369 Err(e) => tool_error(format!(
2370 "failed to invoke '{command}' for contract recording: {e}"
2371 )),
2372 }
2373 }
2374 IntrospectAction::ContractCheck => {
2375 let baselines = self.state.contract_store.all();
2376 if baselines.is_empty() {
2377 return json_result(&serde_json::json!({
2378 "checked": 0,
2379 "message": "no contract baselines recorded — use contract_record first",
2380 }));
2381 }
2382 let mut results = Vec::new();
2383 for baseline in &baselines {
2384 let args_str =
2385 serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2386 let code = format!(
2387 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2388 js_string(&baseline.command)
2389 );
2390 match self
2391 .eval_with_return(&code, params.webview_label.as_deref())
2392 .await
2393 {
2394 Ok(result_str) => {
2395 let value: serde_json::Value = serde_json::from_str(&result_str)
2396 .unwrap_or(serde_json::Value::String(result_str));
2397 let current_shape = crate::introspection::JsonShape::from_value(&value);
2398 let drift = crate::introspection::diff_shapes(
2399 &baseline.shape,
2400 ¤t_shape,
2401 &baseline.command,
2402 );
2403 results.push(drift);
2404 }
2405 Err(e) => {
2406 results.push(crate::introspection::ContractDrift {
2407 command: baseline.command.clone(),
2408 new_fields: Vec::new(),
2409 removed_fields: Vec::new(),
2410 type_changes: Vec::new(),
2411 shape_matches: false,
2412 });
2413 tracing::warn!(
2414 command = %baseline.command,
2415 error = %e,
2416 "contract check invocation failed"
2417 );
2418 }
2419 }
2420 }
2421 let passing = results.iter().filter(|r| r.shape_matches).count();
2422 let result = serde_json::json!({
2423 "checked": results.len(),
2424 "passing": passing,
2425 "failing": results.len() - passing,
2426 "contracts": results,
2427 });
2428 json_result(&result)
2429 }
2430 IntrospectAction::ContractList => {
2431 let baselines = self.state.contract_store.all();
2432 let result = serde_json::json!({
2433 "count": baselines.len(),
2434 "baselines": baselines.iter().map(|b| serde_json::json!({
2435 "command": b.command,
2436 "shape_type": b.shape.type_name(),
2437 "recorded_at": b.recorded_at,
2438 })).collect::<Vec<_>>(),
2439 });
2440 json_result(&result)
2441 }
2442 IntrospectAction::ContractClear => {
2443 let cleared = self.state.contract_store.clear();
2444 json_result(&serde_json::json!({
2445 "cleared": cleared,
2446 }))
2447 }
2448 IntrospectAction::StartupTiming => {
2449 let phases = self.state.startup_timeline.report();
2450 let result = serde_json::json!({
2451 "phases": phases,
2452 "total_ms": self.state.startup_timeline.total_ms(),
2453 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2454 });
2455 json_result(&result)
2456 }
2457 IntrospectAction::Capabilities => {
2458 let config = self.bridge.tauri_config();
2459 let live_windows = self.bridge.list_window_labels();
2460
2461 let result = serde_json::json!({
2462 "app": {
2463 "identifier": config.get("identifier"),
2464 "product_name": config.get("product_name"),
2465 "version": config.get("version"),
2466 },
2467 "security": config.get("security"),
2468 "configured_windows": config.get("windows"),
2469 "live_windows": live_windows,
2470 "configured_plugins": config.get("plugins"),
2471 "victauri": {
2472 "registered_commands": self.state.registry.list().len(),
2473 "auth_enabled": self.state.privacy.redaction_enabled,
2474 "privacy_profile": format!("{:?}", self.state.privacy.profile),
2475 "disabled_tools": &self.state.privacy.disabled_tools,
2476 },
2477 });
2478 json_result(&result)
2479 }
2480 #[allow(unused_variables)]
2481 IntrospectAction::DbHealth => {
2482 #[cfg(feature = "sqlite")]
2483 {
2484 let db_path = params.db_path.clone();
2485 match self.run_db_health(db_path.as_deref()).await {
2486 Ok(health) => json_result(&health),
2487 Err(e) => tool_error(format!("db_health failed: {e}")),
2488 }
2489 }
2490 #[cfg(not(feature = "sqlite"))]
2491 {
2492 tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2493 }
2494 }
2495 IntrospectAction::PluginState => {
2496 let recording_active = self.state.recorder.is_recording();
2497 let recording_events = self.state.recorder.event_count();
2498 let result = serde_json::json!({
2499 "event_log": {
2500 "size": self.state.event_log.len(),
2501 "capacity": self.state.event_log.capacity(),
2502 },
2503 "registry": {
2504 "commands_registered": self.state.registry.list().len(),
2505 },
2506 "recording": {
2507 "active": recording_active,
2508 "events_captured": recording_events,
2509 },
2510 "faults": {
2511 "active_rules": self.state.fault_registry.list().len(),
2512 },
2513 "contracts": {
2514 "baselines_recorded": self.state.contract_store.all().len(),
2515 },
2516 "timings": {
2517 "commands_profiled": self.state.command_timings.all_stats().len(),
2518 },
2519 "event_bus": {
2520 "captured_events": self.state.event_bus.len(),
2521 },
2522 "tasks": {
2523 "total": self.state.task_tracker.list().len(),
2524 "active": self.state.task_tracker.active_count(),
2525 },
2526 "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2527 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2528 "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2529 });
2530 json_result(&result)
2531 }
2532 IntrospectAction::Processes => {
2533 let pid = std::process::id();
2534 let uptime = self.state.started_at.elapsed();
2535 let children = crate::introspection::enumerate_child_processes();
2536 let host_memory = crate::memory::current_stats();
2537
2538 let result = serde_json::json!({
2539 "host": {
2540 "pid": pid,
2541 "uptime_secs": uptime.as_secs(),
2542 "platform": std::env::consts::OS,
2543 "arch": std::env::consts::ARCH,
2544 "memory": host_memory,
2545 },
2546 "children": children.iter().map(|c| serde_json::json!({
2547 "pid": c.pid,
2548 "name": c.name,
2549 "memory_bytes": c.memory_bytes,
2550 })).collect::<Vec<_>>(),
2551 "child_count": children.len(),
2552 "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2553 });
2554 json_result(&result)
2555 }
2556 IntrospectAction::PluginTasks => {
2557 let tasks = self.state.task_tracker.list();
2558 let active = self.state.task_tracker.active_count();
2559 let result = serde_json::json!({
2560 "total": tasks.len(),
2561 "active": active,
2562 "finished": tasks.len() - active,
2563 "tasks": tasks,
2564 });
2565 json_result(&result)
2566 }
2567 IntrospectAction::EventBus => {
2568 let tauri_events = self.state.event_bus.events();
2569 let app_events = self.state.event_log.snapshot();
2570 let result = serde_json::json!({
2571 "tauri_events": {
2572 "count": tauri_events.len(),
2573 "events": tauri_events,
2574 },
2575 "app_events": {
2576 "count": app_events.len(),
2577 "capacity": self.state.event_log.capacity(),
2578 "events": app_events,
2579 },
2580 });
2581 json_result(&result)
2582 }
2583 IntrospectAction::EventBusClear => {
2584 let tauri_cleared = self.state.event_bus.clear();
2585 self.state.event_log.clear();
2586 json_result(&serde_json::json!({
2587 "tauri_events_cleared": tauri_cleared,
2588 "app_events_cleared": true,
2589 }))
2590 }
2591 }
2592 }
2593
2594 #[tool(
2597 description = "Inject faults into Tauri IPC commands at the Rust layer for chaos engineering. \
2598 Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2599 CDP cannot inject failures at the backend — it can only observe the frontend.\n\n\
2600 Actions:\n\
2601 - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2602 - `list`: List all active fault injection rules.\n\
2603 - `clear`: Remove a specific fault rule (requires `command`).\n\
2604 - `clear_all`: Remove all fault rules.",
2605 annotations(
2606 read_only_hint = false,
2607 destructive_hint = true,
2608 idempotent_hint = false,
2609 open_world_hint = false
2610 )
2611 )]
2612 async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2613 self.track_tool_call();
2614 if !self.state.privacy.is_tool_enabled("fault") {
2615 return tool_disabled("fault");
2616 }
2617
2618 match params.action {
2619 FaultAction::Inject => {
2620 let Some(command) = params.command else {
2621 return missing_param("command", "inject");
2622 };
2623 let Some(fault_kind) = params.fault_type else {
2624 return missing_param("fault_type", "inject");
2625 };
2626 let fault_type = match fault_kind {
2627 FaultKind::Delay => {
2628 let delay_ms = params.delay_ms.unwrap_or(1000);
2629 crate::introspection::FaultType::Delay { delay_ms }
2630 }
2631 FaultKind::Error => {
2632 let message = params
2633 .error_message
2634 .unwrap_or_else(|| "injected fault".to_string());
2635 crate::introspection::FaultType::Error { message }
2636 }
2637 FaultKind::Drop => crate::introspection::FaultType::Drop,
2638 FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2639 };
2640 let config = crate::introspection::FaultConfig {
2641 command: command.clone(),
2642 fault_type: fault_type.clone(),
2643 trigger_count: 0,
2644 max_triggers: params.max_triggers.unwrap_or(0),
2645 created_at: std::time::Instant::now(),
2646 };
2647 self.state.fault_registry.inject(config);
2648 let result = serde_json::json!({
2649 "injected": true,
2650 "command": command,
2651 "fault_type": fault_type,
2652 "max_triggers": params.max_triggers.unwrap_or(0),
2653 });
2654 json_result(&result)
2655 }
2656 FaultAction::List => {
2657 let faults = self.state.fault_registry.list();
2658 let result = serde_json::json!({
2659 "count": faults.len(),
2660 "faults": faults.iter().map(|f| serde_json::json!({
2661 "command": f.command,
2662 "fault_type": f.fault_type,
2663 "trigger_count": f.trigger_count,
2664 "max_triggers": f.max_triggers,
2665 })).collect::<Vec<_>>(),
2666 });
2667 json_result(&result)
2668 }
2669 FaultAction::Clear => {
2670 let Some(command) = params.command else {
2671 return missing_param("command", "clear");
2672 };
2673 let removed = self.state.fault_registry.clear(&command);
2674 json_result(&serde_json::json!({
2675 "removed": removed,
2676 "command": command,
2677 }))
2678 }
2679 FaultAction::ClearAll => {
2680 let removed = self.state.fault_registry.clear_all();
2681 json_result(&serde_json::json!({
2682 "removed": removed,
2683 }))
2684 }
2685 }
2686 }
2687
2688 #[tool(
2691 description = "Correlate recent activity across all layers into a coherent narrative. \
2692 CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
2693 + window events across the Rust backend and webview simultaneously.\n\n\
2694 Actions:\n\
2695 - `summary`: High-level activity summary for the last N seconds (default 30). \
2696 Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
2697 - `last_action`: Correlate the most recent burst of events into a causal timeline \
2698 (e.g. 'IPC call → DOM update → console.log').\n\
2699 - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
2700 annotations(
2701 read_only_hint = true,
2702 destructive_hint = false,
2703 idempotent_hint = true,
2704 open_world_hint = false
2705 )
2706 )]
2707 async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
2708 self.track_tool_call();
2709 if !self.state.privacy.is_tool_enabled("explain") {
2710 return tool_disabled("explain");
2711 }
2712
2713 match params.action {
2714 ExplainAction::Summary => {
2715 let secs = params.seconds.unwrap_or(30);
2716 let since = chrono::Utc::now()
2717 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2718 let events = self.state.event_log.since(since);
2719
2720 let mut ipc_count = 0u64;
2721 let mut dom_mutations = 0u64;
2722 let mut state_changes = 0u64;
2723 let mut console_count = 0u64;
2724 let mut window_events = 0u64;
2725 let mut interactions = 0u64;
2726 let mut top_commands: HashMap<String, u64> = HashMap::new();
2727 let mut errors: Vec<String> = Vec::new();
2728
2729 for event in &events {
2730 match event {
2731 victauri_core::AppEvent::Ipc(call) => {
2732 ipc_count += 1;
2733 *top_commands.entry(call.command.clone()).or_insert(0) += 1;
2734 if let victauri_core::IpcResult::Err(e) = &call.result {
2735 errors.push(format!("IPC {}: {e}", call.command));
2736 }
2737 }
2738 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2739 dom_mutations += u64::from(*mutation_count)
2740 }
2741 victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
2742 victauri_core::AppEvent::Console { level, message, .. } => {
2743 console_count += 1;
2744 if level == "error" {
2745 errors.push(format!("console.error: {message}"));
2746 }
2747 }
2748 victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
2749 victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
2750 _ => {}
2751 }
2752 }
2753
2754 let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
2755 sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
2756 let top: Vec<_> = sorted_cmds.iter().take(5).collect();
2757
2758 let narrative = format!(
2759 "{ipc_count} IPC call{} in the last {secs}s{}. \
2760 {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
2761 {console_count} console message{}, {window_events} window event{}. {}.",
2762 if ipc_count == 1 { "" } else { "s" },
2763 if top.is_empty() {
2764 String::new()
2765 } else {
2766 format!(
2767 ", dominated by {}",
2768 top.iter()
2769 .map(|(cmd, n)| format!("{cmd} ({n}x)"))
2770 .collect::<Vec<_>>()
2771 .join(", ")
2772 )
2773 },
2774 if dom_mutations == 1 { "" } else { "s" },
2775 if interactions == 1 { "" } else { "s" },
2776 if console_count == 1 { "" } else { "s" },
2777 if window_events == 1 { "" } else { "s" },
2778 if errors.is_empty() {
2779 "No errors".to_string()
2780 } else {
2781 format!(
2782 "{} error{}",
2783 errors.len(),
2784 if errors.len() == 1 { "" } else { "s" }
2785 )
2786 },
2787 );
2788
2789 let result = serde_json::json!({
2790 "time_window_secs": secs,
2791 "total_events": events.len(),
2792 "ipc_calls": ipc_count,
2793 "dom_mutations": dom_mutations,
2794 "state_changes": state_changes,
2795 "console_messages": console_count,
2796 "window_events": window_events,
2797 "interactions": interactions,
2798 "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
2799 serde_json::json!({"command": cmd, "count": n})
2800 }).collect::<Vec<_>>(),
2801 "errors": errors,
2802 "narrative": narrative,
2803 });
2804 json_result(&result)
2805 }
2806 ExplainAction::LastAction => {
2807 let secs = params.seconds.unwrap_or(5);
2808 let since = chrono::Utc::now()
2809 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2810 let events = self.state.event_log.since(since);
2811
2812 let timeline: Vec<serde_json::Value> = events
2813 .iter()
2814 .filter(|e| !e.is_internal())
2815 .map(|event| match event {
2816 victauri_core::AppEvent::Ipc(call) => serde_json::json!({
2817 "time": call.timestamp.to_rfc3339_opts(
2818 chrono::SecondsFormat::Millis, true
2819 ),
2820 "type": "ipc",
2821 "detail": format!(
2822 "{} {} ({}ms)",
2823 call.command,
2824 call.result,
2825 call.duration_ms.unwrap_or(0)
2826 ),
2827 }),
2828 victauri_core::AppEvent::DomMutation {
2829 timestamp,
2830 mutation_count,
2831 webview_label,
2832 } => serde_json::json!({
2833 "time": timestamp.to_rfc3339_opts(
2834 chrono::SecondsFormat::Millis, true
2835 ),
2836 "type": "dom_mutation",
2837 "detail": format!(
2838 "{mutation_count} element{} updated in {webview_label}",
2839 if *mutation_count == 1 { "" } else { "s" }
2840 ),
2841 }),
2842 victauri_core::AppEvent::DomInteraction {
2843 timestamp,
2844 action,
2845 selector,
2846 ..
2847 } => serde_json::json!({
2848 "time": timestamp.to_rfc3339_opts(
2849 chrono::SecondsFormat::Millis, true
2850 ),
2851 "type": "interaction",
2852 "detail": format!("{action} on {selector}"),
2853 }),
2854 victauri_core::AppEvent::StateChange {
2855 timestamp,
2856 key,
2857 caused_by,
2858 } => serde_json::json!({
2859 "time": timestamp.to_rfc3339_opts(
2860 chrono::SecondsFormat::Millis, true
2861 ),
2862 "type": "state_change",
2863 "detail": format!(
2864 "{key} changed{}",
2865 caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
2866 ),
2867 }),
2868 victauri_core::AppEvent::Console {
2869 timestamp,
2870 level,
2871 message,
2872 } => serde_json::json!({
2873 "time": timestamp.to_rfc3339_opts(
2874 chrono::SecondsFormat::Millis, true
2875 ),
2876 "type": "console",
2877 "detail": format!("console.{level}: {message}"),
2878 }),
2879 victauri_core::AppEvent::WindowEvent {
2880 timestamp,
2881 label,
2882 event,
2883 } => serde_json::json!({
2884 "time": timestamp.to_rfc3339_opts(
2885 chrono::SecondsFormat::Millis, true
2886 ),
2887 "type": "window_event",
2888 "detail": format!("{event} on window '{label}'"),
2889 }),
2890 _ => serde_json::json!({
2891 "time": event.timestamp().to_rfc3339_opts(
2892 chrono::SecondsFormat::Millis, true
2893 ),
2894 "type": "other",
2895 "detail": "unknown event type",
2896 }),
2897 })
2898 .collect();
2899
2900 let narrative = if timeline.is_empty() {
2901 format!("No activity in the last {secs}s.")
2902 } else {
2903 let parts: Vec<String> = timeline
2904 .iter()
2905 .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
2906 .map(String::from)
2907 .collect();
2908 parts.join(" → ")
2909 };
2910
2911 let result = serde_json::json!({
2912 "time_window_secs": secs,
2913 "event_count": timeline.len(),
2914 "timeline": timeline,
2915 "narrative": narrative,
2916 });
2917 json_result(&result)
2918 }
2919 ExplainAction::Diff => {
2920 let secs = params.seconds.unwrap_or(10);
2921 let since = chrono::Utc::now()
2922 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
2923 let events = self.state.event_log.since(since);
2924
2925 let mut ipc_commands: Vec<String> = Vec::new();
2926 let mut dom_changes = 0u64;
2927 let mut error_count = 0u64;
2928 let mut interaction_count = 0u64;
2929 let mut console_messages = 0u64;
2930
2931 for event in &events {
2932 if event.is_internal() {
2933 continue;
2934 }
2935 match event {
2936 victauri_core::AppEvent::Ipc(call) => {
2937 ipc_commands.push(call.command.clone());
2938 if matches!(call.result, victauri_core::IpcResult::Err(_)) {
2939 error_count += 1;
2940 }
2941 }
2942 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
2943 dom_changes += u64::from(*mutation_count)
2944 }
2945 victauri_core::AppEvent::DomInteraction { .. } => {
2946 interaction_count += 1;
2947 }
2948 victauri_core::AppEvent::Console { level, .. } => {
2949 console_messages += 1;
2950 if level == "error" {
2951 error_count += 1;
2952 }
2953 }
2954 _ => {}
2955 }
2956 }
2957
2958 ipc_commands.dedup();
2959
2960 let result = serde_json::json!({
2961 "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2962 "time_window_secs": secs,
2963 "total_events": events.len(),
2964 "ipc_calls_made": ipc_commands.len(),
2965 "unique_commands": ipc_commands,
2966 "dom_elements_changed": dom_changes,
2967 "interactions": interaction_count,
2968 "console_messages": console_messages,
2969 "errors": error_count,
2970 });
2971 json_result(&result)
2972 }
2973 }
2974 }
2975}
2976
2977impl VictauriMcpHandler {
2978 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
2980 Self {
2981 state,
2982 bridge,
2983 subscriptions: Arc::new(Mutex::new(HashSet::new())),
2984 bridge_checked: Arc::new(AtomicBool::new(false)),
2985 probed_labels: Arc::new(Mutex::new(HashSet::new())),
2986 timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
2987 }
2988 }
2989
2990 pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
2991 self.state.privacy.is_tool_enabled(name)
2992 }
2993
2994 pub(crate) async fn execute_tool(
2995 &self,
2996 name: &str,
2997 args: serde_json::Value,
2998 ) -> Result<CallToolResult, rest::ToolCallError> {
2999 if !self.state.privacy.is_tool_enabled(name) {
3000 return Ok(tool_disabled(name));
3001 }
3002 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3003 let start = std::time::Instant::now();
3004 tracing::debug!(tool = %name, "REST tool invocation started");
3005
3006 let result = match name {
3007 "eval_js" => {
3008 let p: EvalJsParams = Self::parse_args(args)?;
3009 self.eval_js(Parameters(p)).await
3010 }
3011 "dom_snapshot" => {
3012 let p: SnapshotParams = Self::parse_args(args)?;
3013 self.dom_snapshot(Parameters(p)).await
3014 }
3015 "find_elements" => {
3016 let p: FindElementsParams = Self::parse_args(args)?;
3017 self.find_elements(Parameters(p)).await
3018 }
3019 "invoke_command" => {
3020 let p: InvokeCommandParams = Self::parse_args(args)?;
3021 self.invoke_command(Parameters(p)).await
3022 }
3023 "screenshot" => {
3024 let p: ScreenshotParams = Self::parse_args(args)?;
3025 self.screenshot(Parameters(p)).await
3026 }
3027 "verify_state" => {
3028 let p: VerifyStateParams = Self::parse_args(args)?;
3029 self.verify_state(Parameters(p)).await
3030 }
3031 "detect_ghost_commands" => {
3032 let p: GhostCommandParams = Self::parse_args(args)?;
3033 self.detect_ghost_commands(Parameters(p)).await
3034 }
3035 "check_ipc_integrity" => {
3036 let p: IpcIntegrityParams = Self::parse_args(args)?;
3037 self.check_ipc_integrity(Parameters(p)).await
3038 }
3039 "wait_for" => {
3040 let p: WaitForParams = Self::parse_args(args)?;
3041 self.wait_for(Parameters(p)).await
3042 }
3043 "assert_semantic" => {
3044 let p: SemanticAssertParams = Self::parse_args(args)?;
3045 self.assert_semantic(Parameters(p)).await
3046 }
3047 "resolve_command" => {
3048 let p: ResolveCommandParams = Self::parse_args(args)?;
3049 self.resolve_command(Parameters(p)).await
3050 }
3051 "get_registry" => {
3052 let p: RegistryParams = Self::parse_args(args)?;
3053 self.get_registry(Parameters(p)).await
3054 }
3055 "get_memory_stats" => self.get_memory_stats().await,
3056 "get_plugin_info" => self.get_plugin_info().await,
3057 "get_diagnostics" => {
3058 let p: DiagnosticsParams = Self::parse_args(args)?;
3059 self.get_diagnostics(Parameters(p)).await
3060 }
3061 "app_info" => self.app_info().await,
3062 "list_app_dir" => {
3063 let p: ListAppDirParams = Self::parse_args(args)?;
3064 self.list_app_dir(Parameters(p)).await
3065 }
3066 "read_app_file" => {
3067 let p: ReadAppFileParams = Self::parse_args(args)?;
3068 self.read_app_file(Parameters(p)).await
3069 }
3070 #[cfg(feature = "sqlite")]
3071 "query_db" => {
3072 let p: QueryDbParams = Self::parse_args(args)?;
3073 self.query_db(Parameters(p)).await
3074 }
3075 "interact" => {
3076 let p: InteractParams = Self::parse_args(args)?;
3077 self.interact(Parameters(p)).await
3078 }
3079 "input" => {
3080 let p: InputParams = Self::parse_args(args)?;
3081 self.input(Parameters(p)).await
3082 }
3083 "window" => {
3084 let p: WindowParams = Self::parse_args(args)?;
3085 self.window(Parameters(p)).await
3086 }
3087 "storage" => {
3088 let p: StorageParams = Self::parse_args(args)?;
3089 self.storage(Parameters(p)).await
3090 }
3091 "navigate" => {
3092 let p: NavigateParams = Self::parse_args(args)?;
3093 self.navigate(Parameters(p)).await
3094 }
3095 "recording" => {
3096 let p: RecordingParams = Self::parse_args(args)?;
3097 self.recording(Parameters(p)).await
3098 }
3099 "inspect" => {
3100 let p: InspectParams = Self::parse_args(args)?;
3101 self.inspect(Parameters(p)).await
3102 }
3103 "css" => {
3104 let p: CssParams = Self::parse_args(args)?;
3105 self.css(Parameters(p)).await
3106 }
3107 "route" => {
3108 let p: RouteParams = Self::parse_args(args)?;
3109 self.route(Parameters(p)).await
3110 }
3111 "trace" => {
3112 let p: TraceParams = Self::parse_args(args)?;
3113 self.trace(Parameters(p)).await
3114 }
3115 "logs" => {
3116 let p: LogsParams = Self::parse_args(args)?;
3117 self.logs(Parameters(p)).await
3118 }
3119 "introspect" => {
3120 let p: IntrospectParams = Self::parse_args(args)?;
3121 self.introspect(Parameters(p)).await
3122 }
3123 "fault" => {
3124 let p: FaultParams = Self::parse_args(args)?;
3125 self.fault(Parameters(p)).await
3126 }
3127 "explain" => {
3128 let p: ExplainParams = Self::parse_args(args)?;
3129 self.explain(Parameters(p)).await
3130 }
3131 _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3132 };
3133
3134 let elapsed = start.elapsed();
3135 tracing::debug!(
3136 tool = %name,
3137 elapsed_ms = elapsed.as_millis() as u64,
3138 "REST tool invocation completed"
3139 );
3140
3141 if self.state.privacy.redaction_enabled {
3142 Ok(Self::redact_result(result, &self.state.privacy))
3143 } else {
3144 Ok(result)
3145 }
3146 }
3147
3148 fn parse_args<T: serde::de::DeserializeOwned>(
3149 args: serde_json::Value,
3150 ) -> Result<T, rest::ToolCallError> {
3151 serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3152 }
3153
3154 fn redact_result(
3155 mut result: CallToolResult,
3156 privacy: &crate::privacy::PrivacyConfig,
3157 ) -> CallToolResult {
3158 for item in &mut result.content {
3159 if let RawContent::Text(ref mut tc) = item.raw {
3160 tc.text = privacy.redact_output(&tc.text);
3161 }
3162 }
3163 result
3164 }
3165
3166 fn track_tool_call(&self) {
3167 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3168 }
3169
3170 fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3171 match dir.unwrap_or(AppDir::Data) {
3172 AppDir::Data => self.bridge.app_data_dir(),
3173 AppDir::Config => self.bridge.app_config_dir(),
3174 AppDir::Log => self.bridge.app_log_dir(),
3175 AppDir::LocalData => self.bridge.app_local_data_dir(),
3176 }
3177 }
3178
3179 fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
3180 let canon_base = std::fs::canonicalize(base)
3181 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
3182 let canon_target = std::fs::canonicalize(target)
3183 .map_err(|e| format!("cannot resolve target path: {e}"))?;
3184 if !canon_target.starts_with(&canon_base) {
3185 return Err("path traversal not allowed".to_string());
3186 }
3187 Ok(())
3188 }
3189
3190 fn list_dir_recursive(
3191 dir: &std::path::Path,
3192 base: &std::path::Path,
3193 depth: u32,
3194 max_depth: u32,
3195 pattern: Option<&str>,
3196 entries: &mut Vec<serde_json::Value>,
3197 ) {
3198 let Ok(read_dir) = std::fs::read_dir(dir) else {
3199 return;
3200 };
3201 for entry in read_dir.flatten() {
3202 let path = entry.path();
3203 if path.is_symlink() {
3204 continue;
3205 }
3206 let name = entry.file_name().to_string_lossy().into_owned();
3207 let relative = path
3208 .strip_prefix(base)
3209 .unwrap_or(&path)
3210 .to_string_lossy()
3211 .into_owned();
3212
3213 if let Some(pat) = pattern
3214 && !Self::matches_glob(&name, pat)
3215 && !path.is_dir()
3216 {
3217 continue;
3218 }
3219
3220 let is_dir = path.is_dir();
3221 let meta = std::fs::metadata(&path).ok();
3222
3223 entries.push(serde_json::json!({
3224 "name": name,
3225 "path": relative,
3226 "is_dir": is_dir,
3227 "size": meta.as_ref().map(std::fs::Metadata::len),
3228 "modified": meta.as_ref()
3229 .and_then(|m| m.modified().ok())
3230 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
3231 .unwrap_or_default().as_secs()),
3232 }));
3233
3234 if is_dir && depth < max_depth {
3235 Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
3236 }
3237 }
3238 }
3239
3240 fn matches_glob(name: &str, pattern: &str) -> bool {
3241 if pattern == "*" {
3242 return true;
3243 }
3244 if let Some(suffix) = pattern.strip_prefix("*.") {
3245 return name.ends_with(&format!(".{suffix}"));
3246 }
3247 if let Some(prefix) = pattern.strip_suffix("*") {
3248 return name.starts_with(prefix);
3249 }
3250 name == pattern
3251 }
3252
3253 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
3254 match self.eval_with_return(code, webview_label).await {
3255 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
3256 Err(e) => tool_error(e),
3257 }
3258 }
3259
3260 async fn eval_with_return(
3261 &self,
3262 code: &str,
3263 webview_label: Option<&str>,
3264 ) -> Result<String, String> {
3265 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
3266 .await
3267 }
3268
3269 async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
3270 let id = uuid::Uuid::new_v4().to_string();
3271 let (tx, rx) = tokio::sync::oneshot::channel();
3272 {
3273 let mut pending = self.state.pending_evals.lock().await;
3274 pending.insert(id.clone(), tx);
3275 }
3276 let id_js = js_string(&id);
3277 let probe = format!(
3278 r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
3279 );
3280 if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
3281 self.state.pending_evals.lock().await.remove(&id);
3282 return Err(format!("eval injection failed: {e}"));
3283 }
3284 if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
3285 Ok(())
3286 } else {
3287 self.state.pending_evals.lock().await.remove(&id);
3288 let label = webview_label.unwrap_or("default");
3289 Err(format!(
3290 "bridge not responding on window '{label}' — the window may be hidden, \
3291 missing the victauri capability, or the JS bridge is not loaded"
3292 ))
3293 }
3294 }
3295
3296 async fn eval_with_return_timeout(
3297 &self,
3298 code: &str,
3299 webview_label: Option<&str>,
3300 timeout: std::time::Duration,
3301 ) -> Result<String, String> {
3302 self.track_tool_call();
3303
3304 if !self
3309 .state
3310 .bridge_ready
3311 .load(std::sync::atomic::Ordering::Acquire)
3312 {
3313 let notified = self.state.bridge_notify.notified();
3314 if !self
3315 .state
3316 .bridge_ready
3317 .load(std::sync::atomic::Ordering::Acquire)
3318 {
3319 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
3320 }
3321 }
3322
3323 let label_key =
3326 webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
3327
3328 if webview_label.is_some() {
3331 let already_probed = self.probed_labels.lock().await.contains(&label_key);
3332 if !already_probed {
3333 self.probe_bridge(webview_label).await?;
3334 self.probed_labels.lock().await.insert(label_key.clone());
3335 }
3336 }
3337
3338 if self.timed_out_labels.lock().await.remove(&label_key) {
3345 self.probe_bridge(webview_label).await.map_err(|e| {
3346 format!("{e} (previous eval on this window timed out; the webview may have reloaded or the app stopped responding)")
3347 })?;
3348 }
3349
3350 let id = uuid::Uuid::new_v4().to_string();
3351 let (tx, rx) = tokio::sync::oneshot::channel();
3352
3353 {
3354 let mut pending = self.state.pending_evals.lock().await;
3355 if pending.len() >= MAX_PENDING_EVALS {
3356 return Err(format!(
3357 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
3358 ));
3359 }
3360 pending.insert(id.clone(), tx);
3361 }
3362
3363 let code = if should_prepend_return(code) {
3370 format!("return {}", code.trim())
3371 } else {
3372 code.trim().to_string()
3373 };
3374
3375 let id_js = js_string(&id);
3376 let inject = format!(
3377 r"
3378 (async () => {{
3379 try {{
3380 const __result = await (async () => {{ {code} }})();
3381 const __type = __result === undefined ? 'undefined'
3382 : __result === null ? 'null' : 'value';
3383 const __val = __type === 'undefined' ? null
3384 : __type === 'null' ? null : __result;
3385 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3386 id: {id_js},
3387 result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
3388 }});
3389 }} catch (e) {{
3390 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3391 id: {id_js},
3392 result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
3393 }});
3394 }}
3395 }})();
3396 "
3397 );
3398
3399 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
3400 self.state.pending_evals.lock().await.remove(&id);
3401 return Err(format!("eval injection failed: {e}"));
3402 }
3403
3404 match tokio::time::timeout(timeout, rx).await {
3405 Ok(Ok(raw)) => {
3406 self.check_bridge_version_once();
3407 if raw.len() > MAX_EVAL_RESULT_LEN {
3408 return Err(format!(
3409 "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
3410 raw.len()
3411 ));
3412 }
3413 unwrap_eval_envelope(raw)
3414 }
3415 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
3416 Err(_) => {
3417 self.state.pending_evals.lock().await.remove(&id);
3418 self.timed_out_labels.lock().await.insert(label_key.clone());
3422 Err(format!(
3423 "eval timed out after {}s — the code never resolved. Common causes: a \
3424 JavaScript syntax error in the injected code (parse errors cannot be \
3425 reported by the webview and surface only as this timeout), an unresolved \
3426 promise, an infinite loop, or the webview reloaded / the app stopped \
3427 responding mid-eval. Verify the code parses and resolves; if the app may \
3428 have navigated or crashed, retry (the next call fails fast if the bridge \
3429 is gone).",
3430 timeout.as_secs()
3431 ))
3432 }
3433 }
3434 }
3435
3436 #[cfg(feature = "sqlite")]
3437 async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
3438 let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
3440 for d in [
3441 self.bridge.app_data_dir(),
3442 self.bridge.app_local_data_dir(),
3443 self.bridge.app_config_dir(),
3444 ]
3445 .into_iter()
3446 .flatten()
3447 {
3448 roots.push(d);
3449 }
3450
3451 let path = if let Some(p) = db_path {
3452 let candidate = std::path::Path::new(p);
3453 if candidate.is_absolute() {
3454 if !roots
3455 .iter()
3456 .any(|r| Self::safe_within(r, candidate).is_ok())
3457 {
3458 return Err(format!(
3459 "absolute path '{p}' is not within an allowed directory; \
3460 register its parent via VictauriBuilder::db_search_paths"
3461 ));
3462 }
3463 candidate.to_path_buf()
3464 } else {
3465 roots
3466 .iter()
3467 .map(|r| r.join(p))
3468 .find(|c| c.exists())
3469 .ok_or_else(|| format!("database not found: {p}"))?
3470 }
3471 } else {
3472 roots
3473 .iter()
3474 .flat_map(|r| crate::database::discover_databases(r))
3475 .next()
3476 .ok_or_else(|| {
3477 "no database found in app directories or configured db_search_paths".to_string()
3478 })?
3479 };
3480 let path_str = path
3486 .to_str()
3487 .ok_or_else(|| "invalid path encoding".to_string())?
3488 .to_string();
3489
3490 tokio::task::spawn_blocking(move || {
3491 let conn = rusqlite::Connection::open_with_flags(
3492 &path_str,
3493 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
3494 )
3495 .map_err(|e| format!("cannot open database: {e}"))?;
3496
3497 let journal_mode: String = conn
3498 .pragma_query_value(None, "journal_mode", |r| r.get(0))
3499 .unwrap_or_else(|_| "unknown".to_string());
3500
3501 let page_count: i64 = conn
3502 .pragma_query_value(None, "page_count", |r| r.get(0))
3503 .unwrap_or(0);
3504
3505 let page_size: i64 = conn
3506 .pragma_query_value(None, "page_size", |r| r.get(0))
3507 .unwrap_or(0);
3508
3509 let freelist_count: i64 = conn
3510 .pragma_query_value(None, "freelist_count", |r| r.get(0))
3511 .unwrap_or(0);
3512
3513 let wal_checkpoint: String = if journal_mode == "wal" {
3514 let mut info = String::from("n/a");
3515 let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
3516 let busy: i64 = r.get(0)?;
3517 let checkpointed: i64 = r.get(1)?;
3518 let total: i64 = r.get(2)?;
3519 info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
3520 Ok(())
3521 });
3522 info
3523 } else {
3524 "n/a (not WAL mode)".to_string()
3525 };
3526
3527 let integrity: String = conn
3528 .pragma_query_value(None, "quick_check", |r| r.get(0))
3529 .unwrap_or_else(|_| "failed".to_string());
3530
3531 let db_size_bytes = page_count * page_size;
3532 let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
3533
3534 let mut tables = Vec::new();
3535 if let Ok(mut stmt) =
3536 conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
3537 && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
3538 {
3539 for name in rows.flatten() {
3540 let count: i64 = conn
3541 .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
3542 .unwrap_or(0);
3543 tables.push(serde_json::json!({
3544 "name": name,
3545 "row_count": count,
3546 }));
3547 }
3548 }
3549
3550 Ok(serde_json::json!({
3551 "database": path_str,
3552 "journal_mode": journal_mode,
3553 "page_count": page_count,
3554 "page_size": page_size,
3555 "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
3556 "freelist_count": freelist_count,
3557 "wal_checkpoint": wal_checkpoint,
3558 "integrity_check": integrity,
3559 "tables": tables,
3560 }))
3561 })
3562 .await
3563 .map_err(|e| format!("db health task failed: {e}"))?
3564 }
3565
3566 fn check_bridge_version_once(&self) {
3567 if self.bridge_checked.swap(true, Ordering::Relaxed) {
3568 return;
3569 }
3570 let handler = self.clone();
3571 tokio::spawn(async move {
3572 match handler
3573 .eval_with_return_timeout(
3574 "window.__VICTAURI__?.version",
3575 None,
3576 std::time::Duration::from_secs(5),
3577 )
3578 .await
3579 {
3580 Ok(v) => {
3581 let v = v.trim_matches('"');
3582 if v == BRIDGE_VERSION {
3583 tracing::debug!("Bridge version verified: {v}");
3584 } else {
3585 tracing::warn!(
3586 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
3587 );
3588 }
3589 }
3590 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
3591 }
3592 });
3593 }
3594}
3595
3596const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
3597It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
3598(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
3599(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
3600\n\nBACKEND tools (direct Rust access, no webview needed): \
3601'app_info' (app config, directory paths, discovered databases, process info), \
3602'list_app_dir' (browse app data/config/log directories), \
3603'read_app_file' (read files from app directories), \
3604'query_db' (read-only SQLite queries with auto-discovery). \
3605\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
3606'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
3607capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
3608Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
3609capability/security auditing, database diagnostics, plugin state, child process enumeration, \
3610task tracking, and automatic Tauri event bus monitoring. \
3611'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
3612drops, and response corruption into Tauri commands at the Rust layer. \
3613'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
3614activity across IPC + DOM + console + network + window events into a coherent narrative. \
3615\n\nWEBVIEW tools: \
3616'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
3617'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
3618'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
3619\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
3620\n\nCOMPOUND tools with an 'action' parameter: \
3621'window' (get_state, list, manage, resize, move_to, set_title), \
3622'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
3623set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
3624get_events, events_between, get_replay, export, import, replay), \
3625'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
3626\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
3627get_memory_stats, get_plugin_info, get_diagnostics.";
3628
3629impl ServerHandler for VictauriMcpHandler {
3630 fn get_info(&self) -> ServerInfo {
3631 ServerInfo::new(
3632 ServerCapabilities::builder()
3633 .enable_tools()
3634 .enable_resources()
3635 .enable_resources_subscribe()
3636 .build(),
3637 )
3638 .with_instructions(SERVER_INSTRUCTIONS)
3639 }
3640
3641 async fn list_tools(
3642 &self,
3643 _request: Option<PaginatedRequestParams>,
3644 _context: RequestContext<RoleServer>,
3645 ) -> Result<ListToolsResult, ErrorData> {
3646 let all_tools = Self::tool_router().list_all();
3647 let filtered: Vec<Tool> = all_tools
3648 .into_iter()
3649 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
3650 .collect();
3651 Ok(ListToolsResult {
3652 tools: filtered,
3653 ..Default::default()
3654 })
3655 }
3656
3657 async fn call_tool(
3658 &self,
3659 request: CallToolRequestParams,
3660 context: RequestContext<RoleServer>,
3661 ) -> Result<CallToolResult, ErrorData> {
3662 let tool_name: String = request.name.as_ref().to_owned();
3663 if !self.state.privacy.is_tool_enabled(&tool_name) {
3664 tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
3665 return Ok(tool_disabled(&tool_name));
3666 }
3667 self.state
3668 .tool_invocations
3669 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3670 let start = std::time::Instant::now();
3671 tracing::debug!(tool = %tool_name, "tool invocation started");
3672 let ctx = ToolCallContext::new(self, request, context);
3673 let result = Self::tool_router().call(ctx).await;
3674 let elapsed = start.elapsed();
3675 tracing::debug!(
3676 tool = %tool_name,
3677 elapsed_ms = elapsed.as_millis() as u64,
3678 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
3679 "tool invocation completed"
3680 );
3681
3682 if self.state.privacy.redaction_enabled {
3685 result.map(|mut r| {
3686 for item in &mut r.content {
3687 if let RawContent::Text(ref mut tc) = item.raw {
3688 tc.text = self.state.privacy.redact_output(&tc.text);
3689 }
3690 }
3691 r
3692 })
3693 } else {
3694 result
3695 }
3696 }
3697
3698 fn get_tool(&self, name: &str) -> Option<Tool> {
3699 if !self.state.privacy.is_tool_enabled(name) {
3700 return None;
3701 }
3702 Self::tool_router().get(name).cloned()
3703 }
3704
3705 async fn list_resources(
3706 &self,
3707 _request: Option<PaginatedRequestParams>,
3708 _context: RequestContext<RoleServer>,
3709 ) -> Result<ListResourcesResult, ErrorData> {
3710 Ok(ListResourcesResult {
3711 resources: vec![
3712 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
3713 .with_description(
3714 "Live IPC call log — all commands invoked between frontend and backend",
3715 )
3716 .with_mime_type("application/json")
3717 .no_annotation(),
3718 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
3719 .with_description(
3720 "Current state of all Tauri windows — position, size, visibility, focus",
3721 )
3722 .with_mime_type("application/json")
3723 .no_annotation(),
3724 RawResource::new(RESOURCE_URI_STATE, "state")
3725 .with_description(
3726 "Victauri plugin state — event count, registered commands, memory stats",
3727 )
3728 .with_mime_type("application/json")
3729 .no_annotation(),
3730 ],
3731 ..Default::default()
3732 })
3733 }
3734
3735 async fn read_resource(
3736 &self,
3737 request: ReadResourceRequestParams,
3738 _context: RequestContext<RoleServer>,
3739 ) -> Result<ReadResourceResult, ErrorData> {
3740 let uri = &request.uri;
3741 let json = match uri.as_str() {
3742 RESOURCE_URI_IPC_LOG => {
3743 if let Ok(json) = self
3744 .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
3745 .await
3746 {
3747 json
3748 } else {
3749 let calls = self.state.event_log.ipc_calls();
3750 serde_json::to_string_pretty(&calls)
3751 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3752 }
3753 }
3754 RESOURCE_URI_WINDOWS => {
3755 let states = self.bridge.get_window_states(None);
3756 serde_json::to_string_pretty(&states)
3757 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3758 }
3759 RESOURCE_URI_STATE => {
3760 let state_json = serde_json::json!({
3761 "events_captured": self.state.event_log.len(),
3762 "commands_registered": self.state.registry.count(),
3763 "memory": crate::memory::current_stats(),
3764 "port": self.state.port.load(Ordering::Relaxed),
3765 });
3766 serde_json::to_string_pretty(&state_json)
3767 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
3768 }
3769 _ => {
3770 return Err(ErrorData::resource_not_found(
3771 format!("unknown resource: {uri}"),
3772 None,
3773 ));
3774 }
3775 };
3776
3777 let json = if self.state.privacy.redaction_enabled {
3778 self.state.privacy.redact_output(&json)
3779 } else {
3780 json
3781 };
3782
3783 Ok(ReadResourceResult::new(vec![ResourceContents::text(
3784 json, uri,
3785 )]))
3786 }
3787
3788 async fn subscribe(
3789 &self,
3790 request: SubscribeRequestParams,
3791 _context: RequestContext<RoleServer>,
3792 ) -> Result<(), ErrorData> {
3793 let uri = &request.uri;
3794 match uri.as_str() {
3795 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
3796 self.subscriptions.lock().await.insert(uri.clone());
3797 tracing::info!("Client subscribed to resource: {uri}");
3798 Ok(())
3799 }
3800 _ => Err(ErrorData::resource_not_found(
3801 format!("unknown resource: {uri}"),
3802 None,
3803 )),
3804 }
3805 }
3806
3807 async fn unsubscribe(
3808 &self,
3809 request: UnsubscribeRequestParams,
3810 _context: RequestContext<RoleServer>,
3811 ) -> Result<(), ErrorData> {
3812 self.subscriptions.lock().await.remove(&request.uri);
3813 tracing::info!("Client unsubscribed from resource: {}", request.uri);
3814 Ok(())
3815 }
3816}
3817
3818fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
3825 let mb = MAX_LOG_FIELD_BYTES;
3826 format!(
3827 r"return (function() {{
3828 var MB = {mb};
3829 function trimField(v) {{
3830 if (typeof v === 'string') {{
3831 return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
3832 }}
3833 if (v && typeof v === 'object') {{
3834 var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
3835 if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
3836 }}
3837 return v;
3838 }}
3839 function trimEntry(e) {{
3840 if (e == null || typeof e !== 'object') return e;
3841 var out = Array.isArray(e) ? [] : {{}};
3842 for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
3843 return out;
3844 }}
3845 var arr = {source_expr} || [];
3846 if (arr.length > {limit}) arr = arr.slice(-{limit});
3847 return arr.map(trimEntry);
3848 }})()"
3849 )
3850}
3851
3852fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
3863 if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
3864 if let Some(err) = envelope.get("__victauri_err") {
3865 return Err(format!(
3866 "JavaScript error: {}",
3867 err.as_str().unwrap_or("unknown error")
3868 ));
3869 }
3870 if envelope.get("__victauri_ok").is_some() {
3871 let js_type = envelope
3872 .get("__victauri_type")
3873 .and_then(|t| t.as_str())
3874 .unwrap_or("value");
3875 return match js_type {
3876 "undefined" => Ok("undefined".to_string()),
3877 "null" => Ok("null".to_string()),
3878 _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
3879 .unwrap_or_else(|_| "null".to_string())),
3880 };
3881 }
3882 }
3883 if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
3885 && let Some(idx) = after.rfind(r#","__victauri_type":"#)
3886 {
3887 return Ok(after[..idx].to_string());
3888 }
3889 if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
3890 let msg = after.trim_end_matches('}').trim_matches('"');
3891 return Err(format!("JavaScript error: {msg}"));
3892 }
3893 Ok(raw)
3894}
3895
3896const STMT_STARTS: &[&str] = &[
3898 "return ",
3899 "return;",
3900 "return\n",
3901 "return\t",
3902 "if ",
3903 "if(",
3904 "for ",
3905 "for(",
3906 "while ",
3907 "while(",
3908 "switch ",
3909 "switch(",
3910 "try ",
3911 "try{",
3912 "const ",
3913 "let ",
3914 "var ",
3915 "function ",
3916 "function(",
3917 "function*",
3918 "class ",
3919 "throw ",
3920 "do ",
3921 "do{",
3922 "{",
3923 "async function",
3924 "debugger",
3925];
3926
3927#[derive(PartialEq, Clone, Copy)]
3929enum ScanState {
3930 Code,
3931 SingleQuote,
3932 DoubleQuote,
3933 Template,
3934}
3935
3936fn should_prepend_return(code: &str) -> bool {
3947 use ScanState::{Code, DoubleQuote, SingleQuote, Template};
3948
3949 let code = code.trim();
3950 if code.is_empty() {
3951 return false;
3952 }
3953
3954 if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
3955 return false;
3956 }
3957
3958 let bytes = code.as_bytes();
3959 let mut i = 0;
3960 let mut depth: i32 = 0;
3961 let mut state = ScanState::Code;
3962
3963 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
3964 let is_return_token = |i: usize| -> bool {
3966 let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
3967 prev_ok
3968 && code[i..].starts_with("return")
3969 && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
3970 };
3971
3972 while i < bytes.len() {
3973 let c = bytes[i];
3974 match state {
3975 Code => match c {
3976 b'\'' => state = SingleQuote,
3977 b'"' => state = DoubleQuote,
3978 b'`' => state = Template,
3979 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
3980 while i < bytes.len() && bytes[i] != b'\n' {
3981 i += 1;
3982 }
3983 continue;
3984 }
3985 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
3986 i += 2;
3987 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
3988 i += 1;
3989 }
3990 i += 2;
3991 continue;
3992 }
3993 b'(' | b'[' | b'{' => depth += 1,
3994 b')' | b']' | b'}' => depth -= 1,
3995 b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
3997 b'r' if depth <= 0 && is_return_token(i) => return false,
3999 _ => {}
4000 },
4001 SingleQuote => {
4002 if c == b'\\' {
4003 i += 1;
4004 } else if c == b'\'' {
4005 state = Code;
4006 }
4007 }
4008 DoubleQuote => {
4009 if c == b'\\' {
4010 i += 1;
4011 } else if c == b'"' {
4012 state = Code;
4013 }
4014 }
4015 Template => {
4016 if c == b'\\' {
4017 i += 1;
4018 } else if c == b'`' {
4019 state = Code;
4020 }
4021 }
4022 }
4023 i += 1;
4024 }
4025
4026 true
4027}
4028
4029#[cfg(test)]
4030mod prop_tests {
4031 use super::should_prepend_return;
4036 use proptest::prelude::*;
4037
4038 fn ident() -> impl Strategy<Value = String> {
4040 prop_oneof![
4041 Just("a".to_string()),
4042 Just("x".to_string()),
4043 Just("foo".to_string()),
4044 Just("window.x".to_string()),
4045 Just("document.title".to_string()),
4046 Just("obj.prop".to_string()),
4047 Just("arr[0]".to_string()),
4048 Just("localStorage".to_string()),
4049 ]
4050 }
4051
4052 fn bare_expr() -> impl Strategy<Value = String> {
4055 prop_oneof![
4056 ident(),
4057 (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
4058 (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
4059 ident().prop_map(|a| format!("{a}.length")),
4060 any::<u16>().prop_map(|n| n.to_string()),
4061 ]
4062 }
4063
4064 proptest! {
4065 #[test]
4068 fn never_panics_on_arbitrary_input(s in ".{0,256}") {
4069 let _ = should_prepend_return(&s);
4070 }
4071
4072 #[test]
4074 fn bare_expressions_are_prepended(e in bare_expr()) {
4075 prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
4076 }
4077
4078 #[test]
4081 fn semicolon_multistatement_with_return_never_prepended(
4082 setup in bare_expr(), ret in bare_expr()
4083 ) {
4084 let code = format!("{setup}; return {ret}");
4085 prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
4086 }
4087
4088 #[test]
4090 fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
4091 let code = format!("{pre}\nreturn {ret}");
4092 prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
4093 }
4094
4095 #[test]
4098 fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
4099 let code = format!("'do;not;split return {inner}'");
4101 prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
4102 }
4103 }
4104}
4105
4106#[cfg(test)]
4107mod tests {
4108 use super::*;
4109
4110 #[test]
4111 fn prepend_return_bare_expressions() {
4112 assert!(should_prepend_return("document.title"));
4113 assert!(should_prepend_return("5 + 5"));
4114 assert!(should_prepend_return("\"justexpr\""));
4115 assert!(should_prepend_return("await fetch('/x')"));
4116 assert!(should_prepend_return(
4117 "document.querySelectorAll('a').length"
4118 ));
4119 assert!(should_prepend_return("x ? a : b"));
4120 assert!(should_prepend_return("document.title;"));
4122 assert!(should_prepend_return("'a;b;c'"));
4124 assert!(should_prepend_return("\"x;y\".length"));
4125 assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
4127 }
4128
4129 #[test]
4130 fn no_prepend_for_statement_blocks() {
4131 assert!(!should_prepend_return(
4133 "localStorage.setItem('k','v'); return localStorage.getItem('k')"
4134 ));
4135 assert!(!should_prepend_return(
4136 "window.scrollTo(0,50); return window.scrollY"
4137 ));
4138 assert!(!should_prepend_return("console.log('x'); return 123"));
4139 assert!(!should_prepend_return("window.__z=7; return 'ok'"));
4140 assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
4142 }
4143
4144 #[test]
4145 fn no_prepend_for_statement_keywords() {
4146 assert!(!should_prepend_return("return 42"));
4147 assert!(!should_prepend_return("const x = 1; return x"));
4148 assert!(!should_prepend_return("let y = 2"));
4149 assert!(!should_prepend_return("var z = 3"));
4150 assert!(!should_prepend_return("if (x) { return 1 }"));
4151 assert!(!should_prepend_return("for (const x of y) doThing(x)"));
4152 assert!(!should_prepend_return("throw new Error('x')"));
4153 assert!(!should_prepend_return("function f(){}"));
4154 assert!(!should_prepend_return("{ a: 1 }")); }
4156
4157 #[test]
4158 fn empty_code_no_prepend() {
4159 assert!(!should_prepend_return(""));
4160 assert!(!should_prepend_return(" "));
4161 }
4162
4163 #[test]
4164 fn envelope_unwrap_value() {
4165 assert_eq!(
4166 unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
4167 Ok("\"4DA\"".to_string())
4168 );
4169 assert_eq!(
4170 unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
4171 Ok("42".to_string())
4172 );
4173 }
4174
4175 #[test]
4176 fn envelope_unwrap_undefined_null() {
4177 assert_eq!(
4178 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
4179 Ok("undefined".to_string())
4180 );
4181 assert_eq!(
4182 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
4183 Ok("null".to_string())
4184 );
4185 }
4186
4187 #[test]
4188 fn envelope_unwrap_error() {
4189 let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
4190 assert!(r.unwrap_err().contains("boom"));
4191 }
4192
4193 #[test]
4194 fn envelope_unwrap_deeply_nested_does_not_leak() {
4195 let mut value = String::from("0");
4199 for _ in 0..300 {
4200 value = format!("{{\"n\":{value}}}");
4201 }
4202 let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
4203 let out = unwrap_eval_envelope(raw).unwrap();
4204 assert!(
4205 out.starts_with(r#"{"n":"#),
4206 "deep value should be unwrapped, got: {}",
4207 &out[..out.len().min(40)]
4208 );
4209 assert!(
4210 !out.contains("__victauri_ok"),
4211 "envelope must not leak into the result"
4212 );
4213 }
4214
4215 #[test]
4216 fn js_string_simple() {
4217 assert_eq!(js_string("hello"), "\"hello\"");
4218 }
4219
4220 #[test]
4221 fn js_string_single_quotes() {
4222 let result = js_string("it's a test");
4223 assert!(result.contains("it's a test"));
4224 }
4225
4226 #[test]
4227 fn js_string_double_quotes() {
4228 let result = js_string(r#"say "hello""#);
4229 assert!(result.contains(r#"\""#));
4230 }
4231
4232 #[test]
4233 fn js_string_backslashes() {
4234 let result = js_string(r"path\to\file");
4235 assert!(result.contains(r"\\"));
4236 }
4237
4238 #[test]
4239 fn js_string_newlines_and_tabs() {
4240 let result = js_string("line1\nline2\ttab");
4241 assert!(result.contains(r"\n"));
4242 assert!(result.contains(r"\t"));
4243 assert!(!result.contains('\n'));
4244 }
4245
4246 #[test]
4247 fn js_string_null_bytes() {
4248 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
4249 let result = js_string(&input);
4250 assert!(result.contains("\\u0000"));
4252 assert!(!result.contains('\0'));
4253 }
4254
4255 #[test]
4256 fn js_string_template_literal_injection() {
4257 let result = js_string("`${alert(1)}`");
4258 assert!(result.starts_with('"'));
4261 assert!(result.ends_with('"'));
4262 }
4263
4264 #[test]
4265 fn js_string_unicode_separators() {
4266 let result = js_string("a\u{2028}b\u{2029}c");
4271 let decoded: String = serde_json::from_str(&result).unwrap();
4273 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
4274 }
4275
4276 #[test]
4277 fn js_string_empty() {
4278 assert_eq!(js_string(""), "\"\"");
4279 }
4280
4281 #[test]
4282 fn js_string_html_script_close() {
4283 let result = js_string("</script><img onerror=alert(1)>");
4285 assert!(result.starts_with('"'));
4286 let decoded: String = serde_json::from_str(&result).unwrap();
4288 assert_eq!(decoded, "</script><img onerror=alert(1)>");
4289 }
4290
4291 #[test]
4292 fn js_string_very_long() {
4293 let long = "a".repeat(100_000);
4294 let result = js_string(&long);
4295 assert!(result.len() >= 100_002); }
4297
4298 #[test]
4301 fn url_allows_http() {
4302 assert!(validate_url("http://example.com", false).is_ok());
4303 }
4304
4305 #[test]
4306 fn url_allows_https() {
4307 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
4308 }
4309
4310 #[test]
4311 fn url_allows_http_localhost() {
4312 assert!(validate_url("http://localhost:3000", false).is_ok());
4313 }
4314
4315 #[test]
4316 fn url_blocks_file_by_default() {
4317 let err = validate_url("file:///etc/passwd", false).unwrap_err();
4318 assert!(err.contains("file"), "error should mention the file scheme");
4319 }
4320
4321 #[test]
4322 fn url_allows_file_when_opted_in() {
4323 assert!(validate_url("file:///tmp/test.html", true).is_ok());
4324 }
4325
4326 #[test]
4327 fn url_blocks_javascript() {
4328 assert!(validate_url("javascript:alert(1)", false).is_err());
4329 }
4330
4331 #[test]
4332 fn url_blocks_javascript_case_insensitive() {
4333 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
4334 }
4335
4336 #[test]
4337 fn url_blocks_data_scheme() {
4338 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
4339 }
4340
4341 #[test]
4342 fn url_blocks_vbscript() {
4343 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
4344 }
4345
4346 #[test]
4347 fn url_rejects_invalid() {
4348 assert!(validate_url("not a url at all", false).is_err());
4349 }
4350
4351 #[test]
4352 fn url_strips_control_chars() {
4353 let input = format!("http://example{}com", '\0');
4355 assert!(validate_url(&input, false).is_ok());
4356 }
4357
4358 #[test]
4361 fn css_color_valid_hex() {
4362 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
4363 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
4364 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
4365 }
4366
4367 #[test]
4368 fn css_color_valid_rgb() {
4369 assert_eq!(
4370 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
4371 "rgb(255, 0, 0)"
4372 );
4373 assert_eq!(
4374 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
4375 "rgba(0, 0, 0, 0.5)"
4376 );
4377 }
4378
4379 #[test]
4380 fn css_color_valid_named() {
4381 assert_eq!(sanitize_css_color("red").unwrap(), "red");
4382 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
4383 }
4384
4385 #[test]
4386 fn css_color_valid_hsl() {
4387 assert_eq!(
4388 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
4389 "hsl(120, 50%, 50%)"
4390 );
4391 }
4392
4393 #[test]
4394 fn css_color_rejects_too_long() {
4395 let long = "a".repeat(101);
4396 assert!(sanitize_css_color(&long).is_err());
4397 }
4398
4399 #[test]
4400 fn css_color_rejects_backslash_escapes() {
4401 assert!(sanitize_css_color(r"red\00").is_err());
4402 assert!(sanitize_css_color(r"\72\65\64").is_err());
4403 }
4404
4405 #[test]
4406 fn css_color_rejects_url_injection() {
4407 assert!(sanitize_css_color("url(http://evil.com)").is_err());
4408 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
4409 }
4410
4411 #[test]
4412 fn css_color_rejects_expression_injection() {
4413 assert!(sanitize_css_color("expression(alert(1))").is_err());
4414 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
4415 }
4416
4417 #[test]
4418 fn css_color_rejects_import() {
4419 assert!(sanitize_css_color("@import url(evil.css)").is_err());
4420 }
4421
4422 #[test]
4423 fn css_color_rejects_semicolons_and_braces() {
4424 assert!(sanitize_css_color("red; background: url(evil)").is_err());
4425 assert!(sanitize_css_color("red} body { color: blue").is_err());
4426 }
4427
4428 #[test]
4429 fn css_color_rejects_special_chars() {
4430 assert!(sanitize_css_color("red<script>").is_err());
4431 assert!(sanitize_css_color("red\"onload=alert").is_err());
4432 assert!(sanitize_css_color("red'onclick=alert").is_err());
4433 }
4434
4435 #[test]
4436 fn css_color_trims_whitespace() {
4437 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
4438 }
4439
4440 #[test]
4441 fn css_color_empty_string() {
4442 assert_eq!(sanitize_css_color("").unwrap(), "");
4443 }
4444}