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