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