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