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