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 #[tool(
1217 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.",
1218 annotations(
1219 read_only_hint = true,
1220 destructive_hint = false,
1221 idempotent_hint = true,
1222 open_world_hint = false
1223 )
1224 )]
1225 async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1226 #[cfg(feature = "sqlite")]
1231 {
1232 self.query_db_impl(params).await
1233 }
1234 #[cfg(not(feature = "sqlite"))]
1235 {
1236 let _ = params;
1237 tool_error(
1238 "query_db is unavailable: this build was compiled without the 'sqlite' \
1239 feature (default-features = false). Re-enable the 'sqlite' feature to use it.",
1240 )
1241 }
1242 }
1243
1244 #[cfg(feature = "sqlite")]
1246 async fn query_db_impl(&self, params: QueryDbParams) -> CallToolResult {
1247 let data_dir = match self.bridge.app_data_dir() {
1248 Ok(d) => d,
1249 Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1250 };
1251
1252 let app_dirs: Vec<std::path::PathBuf> = [
1253 self.bridge.app_data_dir(),
1254 self.bridge.app_config_dir(),
1255 self.bridge.app_local_data_dir(),
1256 self.bridge.app_log_dir(),
1257 ]
1258 .into_iter()
1259 .filter_map(Result::ok)
1260 .collect::<std::collections::HashSet<_>>()
1261 .into_iter()
1262 .collect();
1263 let mut search_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1267 search_dirs.extend(app_dirs);
1268
1269 let db_path = if let Some(ref rel_path) = params.path {
1270 let candidate = std::path::Path::new(rel_path);
1271 if candidate.is_absolute() {
1274 if !candidate.exists() {
1275 return tool_error(format!("database not found: {rel_path}"));
1276 }
1277 if !search_dirs
1278 .iter()
1279 .any(|d| Self::safe_within(d, candidate).is_ok())
1280 {
1281 return tool_error(format!(
1282 "absolute path '{rel_path}' is not within an allowed directory; \
1283 register its parent via VictauriBuilder::db_search_paths"
1284 ));
1285 }
1286 }
1287 let mut found = None;
1288 if candidate.is_absolute() {
1289 found = Some(candidate.to_path_buf());
1290 } else {
1291 for dir in &search_dirs {
1292 let resolved = dir.join(rel_path);
1293 if resolved.exists() {
1294 if let Err(e) = Self::safe_within(dir, &resolved) {
1295 return tool_error(e);
1296 }
1297 found = Some(resolved);
1298 break;
1299 }
1300 }
1301 }
1302 if let Some(p) = found {
1303 p
1304 } else {
1305 let dirs_str = search_dirs
1306 .iter()
1307 .map(|d| d.display().to_string())
1308 .collect::<Vec<_>>()
1309 .join(", ");
1310 return tool_error(format!(
1311 "database not found: {rel_path} (searched: {dirs_str})"
1312 ));
1313 }
1314 } else {
1315 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1322 search_dirs.clone()
1323 } else {
1324 self.state.db_search_paths.clone()
1325 };
1326 match crate::database::select_app_database(&select_dirs) {
1327 Ok(p) => p,
1328 Err(e) => return tool_error(e),
1329 }
1330 };
1331
1332 let db_display = db_path
1333 .strip_prefix(&data_dir)
1334 .unwrap_or(&db_path)
1335 .to_string_lossy()
1336 .into_owned();
1337 let bind_params = params.params.unwrap_or_default();
1338
1339 match crate::database::query(&db_path, ¶ms.query, &bind_params, params.max_rows) {
1340 Ok(mut result) => {
1341 if let Some(obj) = result.as_object_mut() {
1342 obj.insert("database".to_string(), serde_json::json!(db_display));
1343 }
1344 json_result(&result)
1345 }
1346 Err(e) => tool_error(e),
1347 }
1348 }
1349
1350 #[tool(
1353 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.",
1354 annotations(
1355 read_only_hint = false,
1356 destructive_hint = false,
1357 idempotent_hint = false,
1358 open_world_hint = false
1359 )
1360 )]
1361 async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1362 if !self.state.privacy.is_tool_enabled("interact") {
1363 return tool_disabled("interact");
1364 }
1365 match params.action {
1366 InteractAction::Click => {
1367 if !self.state.privacy.is_tool_enabled("interact.click") {
1368 return tool_disabled("interact.click");
1369 }
1370 let Some(ref_id) = ¶ms.ref_id else {
1371 return missing_param("ref_id", "click");
1372 };
1373 if params.trusted.unwrap_or(false) {
1374 let probe = format!(
1377 "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); \
1378 if(!__e) return null; __e.scrollIntoView({{block:'center',inline:'center',behavior:'instant'}}); \
1379 var __b=__e.getBoundingClientRect(); \
1380 return {{x:__b.left+__b.width/2, y:__b.top+__b.height/2}}",
1381 js_string(ref_id)
1382 );
1383 let raw = match self
1384 .eval_with_return(&probe, params.webview_label.as_deref())
1385 .await
1386 {
1387 Ok(r) => r,
1388 Err(e) => return tool_error(e),
1389 };
1390 let Ok(point) = serde_json::from_str::<serde_json::Value>(&raw) else {
1391 return tool_error_with_hint(
1392 format!("ref not found: {ref_id}"),
1393 RecoveryHint::CheckInput,
1394 );
1395 };
1396 let (Some(x), Some(y)) = (
1397 point.get("x").and_then(serde_json::Value::as_f64),
1398 point.get("y").and_then(serde_json::Value::as_f64),
1399 ) else {
1400 return tool_error_with_hint(
1401 format!("ref not found: {ref_id}"),
1402 RecoveryHint::CheckInput,
1403 );
1404 };
1405 return match self
1406 .bridge
1407 .native_click(params.webview_label.as_deref(), x, y)
1408 {
1409 Ok(()) => json_result(
1410 &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1411 ),
1412 Err(e) => tool_error(e),
1413 };
1414 }
1415 let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1416 self.eval_bridge(&code, params.webview_label.as_deref())
1417 .await
1418 }
1419 InteractAction::DoubleClick => {
1420 if !self.state.privacy.is_tool_enabled("interact.double_click") {
1421 return tool_disabled("interact.double_click");
1422 }
1423 let Some(ref_id) = ¶ms.ref_id else {
1424 return missing_param("ref_id", "double_click");
1425 };
1426 let code = format!(
1427 "return window.__VICTAURI__?.doubleClick({})",
1428 js_string(ref_id)
1429 );
1430 self.eval_bridge(&code, params.webview_label.as_deref())
1431 .await
1432 }
1433 InteractAction::Hover => {
1434 if !self.state.privacy.is_tool_enabled("interact.hover") {
1435 return tool_disabled("interact.hover");
1436 }
1437 let Some(ref_id) = ¶ms.ref_id else {
1438 return missing_param("ref_id", "hover");
1439 };
1440 let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1441 self.eval_bridge(&code, params.webview_label.as_deref())
1442 .await
1443 }
1444 InteractAction::Focus => {
1445 if !self.state.privacy.is_tool_enabled("interact.focus") {
1446 return tool_disabled("interact.focus");
1447 }
1448 let Some(ref_id) = ¶ms.ref_id else {
1449 return missing_param("ref_id", "focus");
1450 };
1451 let code = format!(
1452 "return window.__VICTAURI__?.focusElement({})",
1453 js_string(ref_id)
1454 );
1455 self.eval_bridge(&code, params.webview_label.as_deref())
1456 .await
1457 }
1458 InteractAction::ScrollIntoView => {
1459 if !self
1460 .state
1461 .privacy
1462 .is_tool_enabled("interact.scroll_into_view")
1463 {
1464 return tool_disabled("interact.scroll_into_view");
1465 }
1466 let ref_arg = params
1467 .ref_id
1468 .as_ref()
1469 .map_or_else(|| "null".to_string(), |r| js_string(r));
1470 let x = params.x.unwrap_or(0.0);
1471 let y = params.y.unwrap_or(0.0);
1472 let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1473 self.eval_bridge(&code, params.webview_label.as_deref())
1474 .await
1475 }
1476 InteractAction::SelectOption => {
1477 if !self.state.privacy.is_tool_enabled("interact.select_option") {
1478 return tool_disabled("interact.select_option");
1479 }
1480 let Some(ref_id) = ¶ms.ref_id else {
1481 return missing_param("ref_id", "select_option");
1482 };
1483 let values_vec;
1484 let values: &[String] = match (¶ms.values, ¶ms.value) {
1485 (Some(v), _) => v,
1486 (None, Some(v)) => {
1487 values_vec = vec![v.clone()];
1488 &values_vec
1489 }
1490 (None, None) => &[],
1491 };
1492 let values_json =
1493 serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1494 let code = format!(
1495 "return window.__VICTAURI__?.selectOption({}, {})",
1496 js_string(ref_id),
1497 values_json
1498 );
1499 self.eval_bridge(&code, params.webview_label.as_deref())
1500 .await
1501 }
1502 }
1503 }
1504
1505 #[tool(
1506 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.",
1507 annotations(
1508 read_only_hint = false,
1509 destructive_hint = false,
1510 idempotent_hint = false,
1511 open_world_hint = false
1512 )
1513 )]
1514 async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1515 match params.action {
1516 InputAction::Fill => {
1517 if !self.state.privacy.is_tool_enabled("fill") {
1518 return tool_disabled("fill");
1519 }
1520 let Some(ref_id) = ¶ms.ref_id else {
1521 return missing_param("ref_id", "fill");
1522 };
1523 let Some(value) = ¶ms.value else {
1524 return missing_param("value", "fill");
1525 };
1526 let code = format!(
1527 "return window.__VICTAURI__?.fill({}, {})",
1528 js_string(ref_id),
1529 js_string(value)
1530 );
1531 self.eval_bridge(&code, params.webview_label.as_deref())
1532 .await
1533 }
1534 InputAction::TypeText => {
1535 if !self.state.privacy.is_tool_enabled("type_text") {
1536 return tool_disabled("type_text");
1537 }
1538 let Some(ref_id) = ¶ms.ref_id else {
1539 return missing_param("ref_id", "type_text");
1540 };
1541 let Some(text) = ¶ms.text else {
1542 return missing_param("text", "type_text");
1543 };
1544 if params.trusted.unwrap_or(false) {
1545 let focus = format!(
1548 "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1549 js_string(ref_id)
1550 );
1551 let focused = self
1552 .eval_with_return(&focus, params.webview_label.as_deref())
1553 .await
1554 .unwrap_or_default();
1555 if focused != "true" {
1556 return tool_error_with_hint(
1557 format!("ref not found or not focusable: {ref_id}"),
1558 RecoveryHint::CheckInput,
1559 );
1560 }
1561 return match self
1562 .bridge
1563 .native_type_text(params.webview_label.as_deref(), text)
1564 {
1565 Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1566 Err(e) => tool_error(e),
1567 };
1568 }
1569 let code = format!(
1570 "return window.__VICTAURI__?.type({}, {})",
1571 js_string(ref_id),
1572 js_string(text)
1573 );
1574 self.eval_bridge(&code, params.webview_label.as_deref())
1575 .await
1576 }
1577 InputAction::PressKey => {
1578 if !self.state.privacy.is_tool_enabled("input.press_key") {
1579 return tool_disabled("input.press_key");
1580 }
1581 let Some(key) = ¶ms.key else {
1582 return missing_param("key", "press_key");
1583 };
1584 if params.trusted.unwrap_or(false) {
1585 if let Some(ref_id) = ¶ms.ref_id {
1587 let focus = format!(
1588 "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1589 js_string(ref_id)
1590 );
1591 let _ = self
1592 .eval_with_return(&focus, params.webview_label.as_deref())
1593 .await;
1594 }
1595 return match self.bridge.native_key(params.webview_label.as_deref(), key) {
1596 Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1597 Err(e) => tool_error(e),
1598 };
1599 }
1600 let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1601 self.eval_bridge(&code, params.webview_label.as_deref())
1602 .await
1603 }
1604 }
1605 }
1606
1607 #[tool(
1608 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).",
1609 annotations(
1610 read_only_hint = false,
1611 destructive_hint = false,
1612 idempotent_hint = true,
1613 open_world_hint = false
1614 )
1615 )]
1616 async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1617 match params.action {
1618 WindowAction::GetState => {
1619 let states = self.bridge.get_window_states(params.label.as_deref());
1620 if states.is_empty()
1623 && let Some(label) = params.label.as_deref()
1624 {
1625 return tool_error(format!(
1626 "window not found: '{label}' (use window.list to see available labels)"
1627 ));
1628 }
1629 json_result(&states)
1630 }
1631 WindowAction::List => {
1632 let labels = self.bridge.list_window_labels();
1633 json_result(&labels)
1634 }
1635 WindowAction::Introspectability => self.window_introspectability().await,
1636 WindowAction::Manage => {
1637 if !self.state.privacy.is_tool_enabled("window.manage") {
1638 return tool_disabled("window.manage");
1639 }
1640 let Some(manage_action) = ¶ms.manage_action else {
1641 return missing_param("manage_action", "manage");
1642 };
1643 match self
1644 .bridge
1645 .manage_window(params.label.as_deref(), manage_action.as_str())
1646 {
1647 Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1648 Err(e) => tool_error(e),
1649 }
1650 }
1651 WindowAction::Resize => {
1652 if !self.state.privacy.is_tool_enabled("window.resize") {
1653 return tool_disabled("window.resize");
1654 }
1655 let Some(width) = params.width else {
1656 return missing_param("width", "resize");
1657 };
1658 let Some(height) = params.height else {
1659 return missing_param("height", "resize");
1660 };
1661 if width == 0 || height == 0 {
1662 return tool_error_with_hint(
1663 format!(
1664 "invalid window size {width}x{height}: width and height must be > 0"
1665 ),
1666 RecoveryHint::CheckInput,
1667 );
1668 }
1669 match self
1670 .bridge
1671 .resize_window(params.label.as_deref(), width, height)
1672 {
1673 Ok(()) => {
1674 let result =
1675 serde_json::json!({"ok": true, "width": width, "height": height});
1676 CallToolResult::success(vec![Content::text(result.to_string())])
1677 }
1678 Err(e) => tool_error(e),
1679 }
1680 }
1681 WindowAction::MoveTo => {
1682 if !self.state.privacy.is_tool_enabled("window.move_to") {
1683 return tool_disabled("window.move_to");
1684 }
1685 let Some(x) = params.x else {
1686 return missing_param("x", "move_to");
1687 };
1688 let Some(y) = params.y else {
1689 return missing_param("y", "move_to");
1690 };
1691 match self.bridge.move_window(params.label.as_deref(), x, y) {
1692 Ok(()) => {
1693 let result = serde_json::json!({"ok": true, "x": x, "y": y});
1694 CallToolResult::success(vec![Content::text(result.to_string())])
1695 }
1696 Err(e) => tool_error(e),
1697 }
1698 }
1699 WindowAction::SetTitle => {
1700 if !self.state.privacy.is_tool_enabled("window.set_title") {
1701 return tool_disabled("window.set_title");
1702 }
1703 let Some(title) = ¶ms.title else {
1704 return missing_param("title", "set_title");
1705 };
1706 match self.bridge.set_window_title(params.label.as_deref(), title) {
1707 Ok(()) => {
1708 let result = serde_json::json!({"ok": true, "title": title});
1709 CallToolResult::success(vec![Content::text(result.to_string())])
1710 }
1711 Err(e) => tool_error(e),
1712 }
1713 }
1714 }
1715 }
1716
1717 #[tool(
1718 description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1719 annotations(
1720 read_only_hint = false,
1721 destructive_hint = true,
1722 idempotent_hint = false,
1723 open_world_hint = false
1724 )
1725 )]
1726 async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1727 match params.action {
1728 StorageAction::Get => {
1729 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1730 StorageType::Session => "getSessionStorage",
1731 StorageType::Local => "getLocalStorage",
1732 };
1733 let key_arg = params
1734 .key
1735 .as_ref()
1736 .map(|k| js_string(k))
1737 .unwrap_or_default();
1738 let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1739 self.eval_bridge(&code, params.webview_label.as_deref())
1740 .await
1741 }
1742 StorageAction::Set => {
1743 if !self.state.privacy.is_tool_enabled("set_storage") {
1744 return tool_disabled("set_storage");
1745 }
1746 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1747 StorageType::Session => "setSessionStorage",
1748 StorageType::Local => "setLocalStorage",
1749 };
1750 let Some(key) = ¶ms.key else {
1751 return missing_param("key", "set");
1752 };
1753 if !self.state.privacy.is_storage_key_allowed(key) {
1756 return tool_error(format!(
1757 "storage key '{key}' is protected by privacy configuration"
1758 ));
1759 }
1760 let value = params
1761 .value
1762 .as_ref()
1763 .cloned()
1764 .unwrap_or(serde_json::Value::Null);
1765 let value_json =
1766 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1767 let code = format!(
1768 "return window.__VICTAURI__?.{method}({}, {value_json})",
1769 js_string(key)
1770 );
1771 self.eval_bridge(&code, params.webview_label.as_deref())
1772 .await
1773 }
1774 StorageAction::Delete => {
1775 if !self.state.privacy.is_tool_enabled("delete_storage") {
1776 return tool_disabled("delete_storage");
1777 }
1778 let method = match params.storage_type.unwrap_or(StorageType::Local) {
1779 StorageType::Session => "deleteSessionStorage",
1780 StorageType::Local => "deleteLocalStorage",
1781 };
1782 let Some(key) = ¶ms.key else {
1783 return missing_param("key", "delete");
1784 };
1785 let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1786 self.eval_bridge(&code, params.webview_label.as_deref())
1787 .await
1788 }
1789 StorageAction::GetCookies => {
1790 self.eval_bridge(
1791 "return window.__VICTAURI__?.getCookies()",
1792 params.webview_label.as_deref(),
1793 )
1794 .await
1795 }
1796 }
1797 }
1798
1799 #[tool(
1800 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.",
1801 annotations(
1802 read_only_hint = false,
1803 destructive_hint = false,
1804 idempotent_hint = false,
1805 open_world_hint = false
1806 )
1807 )]
1808 async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1809 match params.action {
1810 NavigateAction::GoTo => {
1811 if !self.state.privacy.is_tool_enabled("navigate") {
1812 return tool_disabled("navigate");
1813 }
1814 let Some(url) = ¶ms.url else {
1815 return missing_param("url", "go_to");
1816 };
1817 if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1818 return tool_error(e);
1819 }
1820 let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1821 self.eval_bridge(&code, params.webview_label.as_deref())
1822 .await
1823 }
1824 NavigateAction::GoBack => {
1825 self.eval_bridge(
1826 "return window.__VICTAURI__?.navigateBack()",
1827 params.webview_label.as_deref(),
1828 )
1829 .await
1830 }
1831 NavigateAction::GetHistory => {
1832 self.eval_bridge(
1833 "return window.__VICTAURI__?.getNavigationLog()",
1834 params.webview_label.as_deref(),
1835 )
1836 .await
1837 }
1838 NavigateAction::SetDialogResponse => {
1839 if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1840 return tool_disabled("set_dialog_response");
1841 }
1842 let Some(dialog_type) = params.dialog_type else {
1843 return missing_param("dialog_type", "set_dialog_response");
1844 };
1845 let Some(dialog_action) = params.dialog_action else {
1846 return missing_param("dialog_action", "set_dialog_response");
1847 };
1848 let text_arg = params
1849 .text
1850 .as_ref()
1851 .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1852 let code = format!(
1853 "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1854 js_string(dialog_type.as_str()),
1855 js_string(dialog_action.as_str())
1856 );
1857 self.eval_bridge(&code, params.webview_label.as_deref())
1858 .await
1859 }
1860 NavigateAction::GetDialogLog => {
1861 self.eval_bridge(
1862 "return window.__VICTAURI__?.getDialogLog()",
1863 params.webview_label.as_deref(),
1864 )
1865 .await
1866 }
1867 }
1868 }
1869
1870 #[tool(
1871 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).",
1872 annotations(
1873 read_only_hint = false,
1874 destructive_hint = false,
1875 idempotent_hint = false,
1876 open_world_hint = false
1877 )
1878 )]
1879 async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1880 const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1881 if !self.state.privacy.is_tool_enabled("recording") {
1882 return tool_disabled("recording");
1883 }
1884 match params.action {
1885 RecordingAction::Start => {
1886 let session_id = params
1887 .session_id
1888 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1889 match self.state.recorder.start(session_id.clone()) {
1890 Ok(()) => {
1891 let result = serde_json::json!({
1892 "started": true,
1893 "session_id": session_id,
1894 });
1895 CallToolResult::success(vec![Content::text(result.to_string())])
1896 }
1897 Err(e) => tool_error(e.to_string()),
1898 }
1899 }
1900 RecordingAction::Stop => match self.state.recorder.stop() {
1901 Some(session) => json_result(&session),
1902 None => tool_error("no recording is active"),
1903 },
1904 RecordingAction::Checkpoint => {
1905 let id = params
1910 .checkpoint_id
1911 .unwrap_or_else(|| format!("cp-{}", uuid::Uuid::new_v4()));
1912 let state = params.state.unwrap_or(serde_json::Value::Null);
1913 match self
1914 .state
1915 .recorder
1916 .checkpoint(id.clone(), params.checkpoint_label, state)
1917 {
1918 Ok(()) => {
1919 let result = serde_json::json!({
1920 "created": true,
1921 "checkpoint_id": id,
1922 "event_index": self.state.recorder.event_count(),
1923 });
1924 CallToolResult::success(vec![Content::text(result.to_string())])
1925 }
1926 Err(e) => tool_error(e.to_string()),
1927 }
1928 }
1929 RecordingAction::ListCheckpoints => {
1930 let checkpoints = self.state.recorder.get_checkpoints();
1931 json_result(&checkpoints)
1932 }
1933 RecordingAction::GetEvents => {
1934 let events = self
1935 .state
1936 .recorder
1937 .events_since(params.since_index.unwrap_or(0));
1938 json_result(&events)
1939 }
1940 RecordingAction::EventsBetween => {
1941 let Some(from) = ¶ms.from else {
1942 return missing_param("from", "events_between");
1943 };
1944 let Some(to) = ¶ms.to else {
1945 return missing_param("to", "events_between");
1946 };
1947 match self.state.recorder.events_between_checkpoints(from, to) {
1948 Ok(events) => json_result(&events),
1949 Err(e) => tool_error(e.to_string()),
1950 }
1951 }
1952 RecordingAction::GetReplay => {
1953 let calls = self.state.recorder.ipc_replay_sequence();
1954 json_result(&calls)
1955 }
1956 RecordingAction::Export => match self.state.recorder.export() {
1957 Some(s) => {
1958 let json = serde_json::to_string_pretty(&s)
1959 .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1960 CallToolResult::success(vec![Content::text(json)])
1961 }
1962 None => tool_error("no recording is active — start one first"),
1963 },
1964 RecordingAction::Import => {
1965 let Some(session_json) = ¶ms.session_json else {
1966 return missing_param("session_json", "import");
1967 };
1968 if session_json.len() > MAX_SESSION_JSON {
1969 return tool_error("session JSON exceeds maximum size (10 MB)");
1970 }
1971 let session: victauri_core::RecordedSession =
1972 match serde_json::from_str(session_json) {
1973 Ok(s) => s,
1974 Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1975 };
1976
1977 let result = serde_json::json!({
1978 "imported": true,
1979 "session_id": session.id,
1980 "event_count": session.events.len(),
1981 "checkpoint_count": session.checkpoints.len(),
1982 "started_at": session.started_at.to_rfc3339(),
1983 });
1984 self.state.recorder.import(session);
1985 CallToolResult::success(vec![Content::text(result.to_string())])
1986 }
1987 RecordingAction::Flush => {
1988 if !self.state.recorder.is_recording() {
1989 return tool_error("no active recording — start a recording first");
1990 }
1991 let code = "return window.__VICTAURI__?.getEventStream(0)";
1992 match self
1993 .eval_with_return(code, params.webview_label.as_deref())
1994 .await
1995 {
1996 Ok(result_str) => {
1997 let events: Vec<serde_json::Value> =
1998 serde_json::from_str(&result_str).unwrap_or_default();
1999 let mut count = 0u64;
2000 for ev in &events {
2001 if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
2002 self.state.event_log.push(app_event.clone());
2003 self.state.recorder.record_event(app_event);
2004 count += 1;
2005 }
2006 }
2007 json_result(&serde_json::json!({
2008 "flushed": true,
2009 "events_captured": count,
2010 }))
2011 }
2012 Err(e) => tool_error(format!("flush failed: {e}")),
2013 }
2014 }
2015 RecordingAction::Replay => {
2016 let calls = self.state.recorder.ipc_replay_sequence();
2017 if calls.is_empty() {
2018 return tool_error("no IPC calls recorded — record a session first");
2019 }
2020 let mut replay_results = Vec::new();
2021 for call in &calls {
2022 if !self.state.privacy.is_invoke_allowed(&call.command)
2026 || !self.state.privacy.is_command_allowed(&call.command)
2027 {
2028 replay_results.push(serde_json::json!({
2029 "command": call.command,
2030 "status": "blocked",
2031 "error": "blocked by privacy configuration",
2032 }));
2033 continue;
2034 }
2035 let code = format!(
2036 "return window.__TAURI_INTERNALS__.invoke({})",
2037 js_string(&call.command)
2038 );
2039 let outcome = match self
2040 .eval_with_return(&code, params.webview_label.as_deref())
2041 .await
2042 {
2043 Ok(result_str) => {
2044 let value: serde_json::Value = serde_json::from_str(&result_str)
2045 .unwrap_or(serde_json::Value::String(result_str));
2046 let shape = crate::introspection::JsonShape::from_value(&value);
2047 serde_json::json!({
2048 "command": call.command,
2049 "status": "ok",
2050 "response_type": shape.type_name(),
2051 })
2052 }
2053 Err(e) => {
2054 serde_json::json!({
2055 "command": call.command,
2056 "status": "error",
2057 "error": e,
2058 })
2059 }
2060 };
2061 replay_results.push(outcome);
2062 }
2063 let passed = replay_results
2064 .iter()
2065 .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
2066 .count();
2067 let result = serde_json::json!({
2068 "replayed": replay_results.len(),
2069 "passed": passed,
2070 "failed": replay_results.len() - passed,
2071 "results": replay_results,
2072 });
2073 json_result(&result)
2074 }
2075 }
2076 }
2077
2078 #[tool(
2079 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).",
2080 annotations(
2081 read_only_hint = true,
2082 destructive_hint = false,
2083 idempotent_hint = true,
2084 open_world_hint = false
2085 )
2086 )]
2087 async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
2088 match params.action {
2089 InspectAction::GetStyles => {
2090 let Some(ref_id) = ¶ms.ref_id else {
2091 return missing_param("ref_id", "get_styles");
2092 };
2093 let props_arg = match ¶ms.properties {
2094 Some(props) => {
2095 let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
2096 format!("[{}]", arr.join(","))
2097 }
2098 None => "null".to_string(),
2099 };
2100 let code = format!(
2101 "return window.__VICTAURI__?.getStyles({}, {})",
2102 js_string(ref_id),
2103 props_arg
2104 );
2105 self.eval_bridge(&code, params.webview_label.as_deref())
2106 .await
2107 }
2108 InspectAction::GetBoundingBoxes => {
2109 let Some(ref_ids) = ¶ms.ref_ids else {
2110 return missing_param("ref_ids", "get_bounding_boxes");
2111 };
2112 let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
2113 let code = format!(
2114 "return window.__VICTAURI__?.getBoundingBoxes([{}])",
2115 refs.join(",")
2116 );
2117 self.eval_bridge(&code, params.webview_label.as_deref())
2118 .await
2119 }
2120 InspectAction::Highlight => {
2121 if !self.state.privacy.is_tool_enabled("inspect.highlight") {
2125 return tool_disabled("inspect.highlight");
2126 }
2127 let Some(ref_id) = ¶ms.ref_id else {
2128 return missing_param("ref_id", "highlight");
2129 };
2130 let color_arg = match ¶ms.color {
2131 Some(c) => match sanitize_css_color(c) {
2132 Ok(safe) => format!("\"{safe}\""),
2133 Err(e) => return tool_error(e),
2134 },
2135 None => "null".to_string(),
2136 };
2137 let label_arg = match ¶ms.label {
2138 Some(l) => js_string(l),
2139 None => "null".to_string(),
2140 };
2141 let code = format!(
2142 "return window.__VICTAURI__?.highlightElement({}, {}, {})",
2143 js_string(ref_id),
2144 color_arg,
2145 label_arg
2146 );
2147 self.eval_bridge(&code, params.webview_label.as_deref())
2148 .await
2149 }
2150 InspectAction::ClearHighlights => {
2151 if !self
2152 .state
2153 .privacy
2154 .is_tool_enabled("inspect.clear_highlights")
2155 {
2156 return tool_disabled("inspect.clear_highlights");
2157 }
2158 self.eval_bridge(
2159 "return window.__VICTAURI__?.clearHighlights()",
2160 params.webview_label.as_deref(),
2161 )
2162 .await
2163 }
2164 InspectAction::AuditAccessibility => {
2165 self.eval_bridge(
2166 "return window.__VICTAURI__?.auditAccessibility()",
2167 params.webview_label.as_deref(),
2168 )
2169 .await
2170 }
2171 InspectAction::GetPerformance => {
2172 self.eval_bridge(
2173 "return window.__VICTAURI__?.getPerformanceMetrics()",
2174 params.webview_label.as_deref(),
2175 )
2176 .await
2177 }
2178 }
2179 }
2180
2181 #[tool(
2182 description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
2183 annotations(
2184 read_only_hint = false,
2185 destructive_hint = false,
2186 idempotent_hint = true,
2187 open_world_hint = false
2188 )
2189 )]
2190 async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
2191 match params.action {
2192 CssAction::Inject => {
2193 if !self.state.privacy.is_tool_enabled("inject_css") {
2194 return tool_disabled("inject_css");
2195 }
2196 let Some(css) = ¶ms.css else {
2197 return missing_param("css", "inject");
2198 };
2199 if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
2201 return tool_error(e);
2202 }
2203 let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
2204 self.eval_bridge(&code, params.webview_label.as_deref())
2205 .await
2206 }
2207 CssAction::Remove => {
2208 if !self.state.privacy.is_tool_enabled("css.remove") {
2209 return tool_disabled("css.remove");
2210 }
2211 self.eval_bridge(
2212 "return window.__VICTAURI__?.removeInjectedCss()",
2213 params.webview_label.as_deref(),
2214 )
2215 .await
2216 }
2217 }
2218 }
2219
2220 #[tool(
2221 description = "Network request interception (Playwright route() equivalent, no CDP). \
2222 Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
2223 Actions:\n\
2224 - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
2225 and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
2226 mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
2227 `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
2228 - `list`: list active rules.\n\
2229 - `clear` (by `id`) / `clear_all`: remove rules.\n\
2230 - `matches`: log of intercepted requests.\n\
2231 Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2232 Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2233 Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/\
2234 fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so \
2235 it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the \
2236 `fault` tool only affects commands you drive via `invoke_command`, not real user IPC.",
2237 annotations(
2238 read_only_hint = false,
2239 destructive_hint = false,
2240 idempotent_hint = false,
2241 open_world_hint = false
2242 )
2243 )]
2244 async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2245 match params.action {
2246 RouteAction::Add => {
2247 if !self.state.privacy.is_tool_enabled("route.add") {
2248 return tool_disabled("route.add");
2249 }
2250 let Some(pattern) = ¶ms.pattern else {
2251 return missing_param("pattern", "add");
2252 };
2253 let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2254 let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2255 let mut rule = serde_json::json!({
2256 "pattern": pattern,
2257 "match_type": match_type.as_str(),
2258 "action": behavior.as_str(),
2259 });
2260 if let Some(m) = ¶ms.method {
2261 rule["method"] = serde_json::json!(m);
2262 }
2263 if let Some(s) = params.status {
2264 rule["status"] = serde_json::json!(s);
2265 }
2266 if let Some(st) = ¶ms.status_text {
2267 rule["status_text"] = serde_json::json!(st);
2268 }
2269 if let Some(h) = ¶ms.headers {
2270 rule["headers"] = h.clone();
2271 }
2272 if let Some(b) = ¶ms.body {
2273 rule["body"] = match b {
2276 serde_json::Value::String(s) => serde_json::json!(s),
2277 other => serde_json::json!(other.to_string()),
2278 };
2279 }
2280 if let Some(ct) = ¶ms.content_type {
2281 rule["content_type"] = serde_json::json!(ct);
2282 }
2283 if let Some(d) = params.delay_ms {
2284 rule["delay_ms"] = serde_json::json!(d);
2285 }
2286 if let Some(t) = params.times {
2287 rule["times"] = serde_json::json!(t);
2288 }
2289 let code = format!(
2290 "return window.__VICTAURI__?.addRoute({})",
2291 js_string(&rule.to_string())
2292 );
2293 self.eval_bridge(&code, params.webview_label.as_deref())
2294 .await
2295 }
2296 RouteAction::List => {
2297 self.eval_bridge(
2298 "return window.__VICTAURI__?.getRouteRules()",
2299 params.webview_label.as_deref(),
2300 )
2301 .await
2302 }
2303 RouteAction::Clear => {
2304 let Some(id) = params.id else {
2305 return missing_param("id", "clear");
2306 };
2307 let code = format!("return window.__VICTAURI__?.clearRoute({id})");
2308 self.eval_bridge(&code, params.webview_label.as_deref())
2309 .await
2310 }
2311 RouteAction::ClearAll => {
2312 self.eval_bridge(
2313 "return window.__VICTAURI__?.clearRoutes()",
2314 params.webview_label.as_deref(),
2315 )
2316 .await
2317 }
2318 RouteAction::Matches => {
2319 let limit = params.limit.unwrap_or(100);
2320 let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2321 self.eval_bridge(&code, params.webview_label.as_deref())
2322 .await
2323 }
2324 }
2325 }
2326
2327 #[tool(
2328 description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2329 into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2330 `logs` (network/console). Actions:\n\
2331 - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2332 `with_events=true` to also start the event recorder.\n\
2333 - `stop`: stop and return a summary (frame count, duration, timestamps).\n\
2334 - `status`: active flag + buffered frame count.\n\
2335 - `frames`: return captured frames as base64 PNGs (`limit` caps how many).",
2336 annotations(
2337 read_only_hint = false,
2338 destructive_hint = false,
2339 idempotent_hint = false,
2340 open_world_hint = false
2341 )
2342 )]
2343 async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2344 if !self.state.privacy.is_tool_enabled("trace")
2345 || !self.state.privacy.is_tool_enabled("screenshot")
2346 {
2347 return tool_disabled("trace");
2348 }
2349 match params.action {
2350 TraceAction::Start => {
2351 let interval = params.interval_ms.unwrap_or(500);
2352 let max_frames = params.max_frames.unwrap_or(60);
2353 let label = params.webview_label.clone();
2354 let generation = self
2355 .state
2356 .screencast
2357 .start(interval, max_frames, label.clone());
2358
2359 let mut events_started = false;
2360 if params.with_events.unwrap_or(false) {
2361 let session_id = uuid::Uuid::new_v4().to_string();
2362 if self.state.recorder.start(session_id).is_ok() {
2363 events_started = true;
2364 }
2365 }
2366
2367 let bridge = self.bridge.clone();
2370 let screencast = self.state.screencast.clone();
2371 tokio::spawn(async move {
2372 let t0 = std::time::Instant::now();
2373 while screencast.is_active() && screencast.generation() == generation {
2374 if let Ok(handle) = bridge.get_native_handle(label.as_deref())
2375 && let Ok(png) = crate::screenshot::capture_window(handle).await
2376 {
2377 use base64::Engine;
2378 let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2379 #[allow(clippy::cast_possible_truncation)]
2380 screencast.push_frame(t0.elapsed().as_millis() as u64, b64);
2381 }
2382 tokio::time::sleep(std::time::Duration::from_millis(
2383 screencast.interval_ms(),
2384 ))
2385 .await;
2386 }
2387 });
2388
2389 json_result(&serde_json::json!({
2390 "started": true,
2391 "interval_ms": interval.max(50),
2392 "max_frames": max_frames.clamp(1, 600),
2393 "with_events": events_started,
2394 }))
2395 }
2396 TraceAction::Stop => {
2397 let frame_count = self.state.screencast.stop();
2398 let timestamps = self.state.screencast.frame_timestamps();
2399 let duration_ms = timestamps.last().copied().unwrap_or(0);
2400 let event_count = self.state.recorder.event_count();
2401 json_result(&serde_json::json!({
2402 "stopped": true,
2403 "frame_count": frame_count,
2404 "duration_ms": duration_ms,
2405 "frame_timestamps_ms": timestamps,
2406 "recorded_event_count": event_count,
2407 "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2408 }))
2409 }
2410 TraceAction::Status => json_result(&serde_json::json!({
2411 "active": self.state.screencast.is_active(),
2412 "frame_count": self.state.screencast.frame_count(),
2413 "interval_ms": self.state.screencast.interval_ms(),
2414 })),
2415 TraceAction::Frames => {
2416 let limit = params.limit.unwrap_or(0);
2417 let frames = self.state.screencast.frames(limit);
2418 let items: Vec<Content> = frames
2419 .into_iter()
2420 .map(|f| Content::image(f.data_b64, "image/png"))
2421 .collect();
2422 if items.is_empty() {
2423 return json_result(&serde_json::json!({ "frames": 0 }));
2424 }
2425 CallToolResult::success(items)
2426 }
2427 }
2428 }
2429
2430 #[tool(
2431 description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
2432 the webview's animation engine is actually running — duration, delay, easing, iterations, \
2433 keyframes, current progress, and the animating element. Standard DOM, so it works \
2434 identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
2435 - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
2436 each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
2437 - `scrub`: deterministically pause the target's animation and seek it to `points` \
2438 evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
2439 + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
2440 PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
2441 Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
2442 animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
2443 - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
2444 on `selector` (or the first animating element); then trigger the animation; then call \
2445 with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
2446 max frame gap) and declared-vs-measured duration. Works for ANY animation including \
2447 JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
2448 NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
2449 notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
2450 annotations(
2451 read_only_hint = true,
2452 destructive_hint = false,
2453 idempotent_hint = true,
2454 open_world_hint = false
2455 )
2456 )]
2457 async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
2458 if !self.state.privacy.is_tool_enabled("animation") {
2459 return tool_disabled("animation");
2460 }
2461 match params.action {
2462 AnimationAction::List => {
2463 let sel = params
2464 .selector
2465 .as_deref()
2466 .map_or_else(|| "null".to_string(), js_string);
2467 let code = format!(
2468 "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
2469 );
2470 match self
2471 .eval_with_return(&code, params.webview_label.as_deref())
2472 .await
2473 {
2474 Ok(result_str) => {
2475 match serde_json::from_str::<serde_json::Value>(&result_str) {
2476 Ok(v) => json_result(&v),
2477 Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2478 }
2479 }
2480 Err(e) => tool_error(format!("animation list failed: {e}")),
2481 }
2482 }
2483 AnimationAction::Scrub => self.animation_scrub(params).await,
2484 AnimationAction::Sample => {
2485 let label = params.webview_label.as_deref();
2486 let sel = params
2487 .selector
2488 .as_deref()
2489 .map_or_else(|| "null".to_string(), js_string);
2490 let code = if params.record.unwrap_or(false) {
2491 format!("return window.__VICTAURI__.installSweepRecorder({sel})")
2492 } else {
2493 let clear = params.clear.unwrap_or(false);
2494 format!("return window.__VICTAURI__.readSweep({clear})")
2495 };
2496 match self.eval_with_return(&code, label).await {
2497 Ok(result_str) => {
2498 match serde_json::from_str::<serde_json::Value>(&result_str) {
2499 Ok(v) => json_result(&v),
2500 Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2501 }
2502 }
2503 Err(e) => tool_error(format!("animation sample failed: {e}")),
2504 }
2505 }
2506 }
2507 }
2508
2509 async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
2512 let label = params.webview_label.as_deref();
2513 let sel = params
2514 .selector
2515 .as_deref()
2516 .map_or_else(|| "null".to_string(), js_string);
2517
2518 let prep_code = format!("return await window.__VICTAURI__.scrubPrepare({sel})");
2520 let prep_v = match self.eval_with_return(&prep_code, label).await {
2521 Ok(s) => {
2522 serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
2523 }
2524 Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
2525 };
2526 if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
2527 return json_result(&prep_v);
2529 }
2530
2531 let points = params.points.unwrap_or(20).clamp(2, 120);
2532 let capture = params.capture.unwrap_or(false);
2533 let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
2534 let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
2535 let mut manifest: Vec<serde_json::Value> = Vec::new();
2536
2537 for i in 0..points {
2539 #[allow(clippy::cast_precision_loss)]
2540 let progress = i as f64 / (points - 1) as f64;
2541 let seek_code = format!("return await window.__VICTAURI__.scrubSeek({progress})");
2542 match self.eval_with_return(&seek_code, label).await {
2543 Ok(s) => {
2544 let v = serde_json::from_str::<serde_json::Value>(&s)
2545 .unwrap_or(serde_json::Value::Null);
2546 if capture
2547 && let Ok(handle) = self.bridge.get_native_handle(label)
2548 && let Ok((rgba, w, h)) =
2549 crate::screenshot::capture_window_raw(handle).await
2550 && let Some(frame) = crate::filmstrip::Frame::new(rgba, w, h)
2551 {
2552 manifest.push(serde_json::json!({
2553 "cell": frames.len(),
2554 "progress": progress,
2555 "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
2556 }));
2557 frames.push(frame);
2558 }
2559 curve.push(v);
2560 }
2561 Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
2562 }
2563 }
2564
2565 let resume = params.restore.unwrap_or(true);
2567 let restore_code = format!("return window.__VICTAURI__.scrubRestore({resume})");
2568 let _ = self.eval_with_return(&restore_code, label).await;
2569
2570 let mut meta = serde_json::json!({
2571 "scrubbed": true,
2572 "points": points,
2573 "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
2574 "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
2575 "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
2576 "captured": capture,
2577 "curve": curve,
2578 });
2579
2580 if capture && !frames.is_empty() {
2582 let cols = params
2583 .cols
2584 .unwrap_or_else(|| crate::filmstrip::default_cols(frames.len()));
2585 if let Some((rgba, w, h)) =
2586 crate::filmstrip::compose(&frames, cols, 4, [20, 20, 20, 255])
2587 {
2588 match crate::screenshot::encode_png(w, h, &rgba) {
2589 Ok(png) => {
2590 use base64::Engine;
2591 let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2592 meta["filmstrip"] = serde_json::json!({
2593 "cols": cols,
2594 "frame_count": frames.len(),
2595 "width": w,
2596 "height": h,
2597 "manifest": manifest,
2598 });
2599 return CallToolResult::success(vec![
2600 Content::image(b64, "image/png"),
2601 Content::text(meta.to_string()),
2602 ]);
2603 }
2604 Err(e) => return tool_error(format!("filmstrip encode failed: {e}")),
2605 }
2606 }
2607 }
2608
2609 json_result(&meta)
2610 }
2611
2612 #[tool(
2613 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).",
2614 annotations(
2615 read_only_hint = true,
2616 destructive_hint = false,
2617 idempotent_hint = true,
2618 open_world_hint = false
2619 )
2620 )]
2621 async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
2622 match params.action {
2623 LogsAction::Console => {
2624 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2625 let base = if since_arg.is_empty() {
2626 "window.__VICTAURI__?.getConsoleLogs()".to_string()
2627 } else {
2628 format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
2629 };
2630 let code = if let Some(limit) = params.limit {
2631 format!("return ({base} || []).slice(-{limit})")
2632 } else {
2633 format!("return {base}")
2634 };
2635 self.eval_bridge(&code, params.webview_label.as_deref())
2636 .await
2637 }
2638 LogsAction::Network => {
2639 let filter_arg = params
2640 .filter
2641 .as_ref()
2642 .map_or_else(|| "null".to_string(), |f| js_string(f));
2643 let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2644 let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
2645 let code = trimmed_log_js(&source, limit);
2646 self.eval_bridge(&code, params.webview_label.as_deref())
2647 .await
2648 }
2649 LogsAction::Ipc => {
2650 let wait = params.wait_for_capture.unwrap_or(false);
2651 let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2652 if wait {
2653 let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit);
2654 let code = format!(
2655 r"return (async function() {{
2656 await window.__VICTAURI__.waitForIpcComplete(500);
2657 return (function() {{ {inner} }})();
2658 }})()"
2659 );
2660 let timeout = std::time::Duration::from_millis(5000);
2661 match self
2662 .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
2663 .await
2664 {
2665 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2666 Err(e) => tool_error(e),
2667 }
2668 } else {
2669 let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit);
2670 self.eval_bridge(&code, params.webview_label.as_deref())
2671 .await
2672 }
2673 }
2674 LogsAction::Navigation => {
2675 let code = if let Some(limit) = params.limit {
2676 format!(
2677 "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
2678 )
2679 } else {
2680 "return window.__VICTAURI__?.getNavigationLog()".to_string()
2681 };
2682 self.eval_bridge(&code, params.webview_label.as_deref())
2683 .await
2684 }
2685 LogsAction::Dialogs => {
2686 let code = if let Some(limit) = params.limit {
2687 format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
2688 } else {
2689 "return window.__VICTAURI__?.getDialogLog()".to_string()
2690 };
2691 self.eval_bridge(&code, params.webview_label.as_deref())
2692 .await
2693 }
2694 LogsAction::Events => {
2695 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2696 let base = if since_arg.is_empty() {
2697 "window.__VICTAURI__?.getEventStream()".to_string()
2698 } else {
2699 format!("window.__VICTAURI__?.getEventStream({since_arg})")
2700 };
2701 let code = if let Some(limit) = params.limit {
2702 format!("return ({base} || []).slice(-{limit})")
2703 } else {
2704 format!("return {base}")
2705 };
2706 self.eval_bridge(&code, params.webview_label.as_deref())
2707 .await
2708 }
2709 LogsAction::SlowIpc => {
2710 let Some(threshold) = params.threshold_ms else {
2711 return missing_param("threshold_ms", "slow_ipc");
2712 };
2713 let limit = params.limit.unwrap_or(20);
2714 let mb = MAX_LOG_FIELD_BYTES;
2715 let code = format!(
2716 r"return (function() {{
2717 var MB = {mb};
2718 function trimField(v) {{
2719 if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
2720 if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }}
2721 return v;
2722 }}
2723 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; }}
2724 var log = window.__VICTAURI__?.getIpcLog() || [];
2725 var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
2726 slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
2727 return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }};
2728 }})()",
2729 );
2730 self.eval_bridge(&code, None).await
2731 }
2732 LogsAction::Clear => {
2733 if !self.state.privacy.is_tool_enabled("logs.clear") {
2737 return tool_disabled("logs.clear");
2738 }
2739 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'] }; })()";
2740 self.eval_bridge(code, params.webview_label.as_deref())
2741 .await
2742 }
2743 }
2744 }
2745
2746 #[tool(
2749 description = "Deep backend introspection — command profiling, IPC contract testing, \
2750 coverage, startup timing, capability auditing, database diagnostics, process \
2751 enumeration, and event bus monitoring. \
2752 These features exploit Victauri's position inside the Rust process.\n\n\
2753 Actions:\n\
2754 - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
2755 - `coverage`: Which registered commands have been called during this session.\n\
2756 - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
2757 - `contract_check`: Check all recorded contracts for schema drift.\n\
2758 - `contract_list`: List all recorded contract baselines.\n\
2759 - `contract_clear`: Clear all recorded contract baselines.\n\
2760 - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
2761 - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
2762 - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
2763 - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
2764 - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
2765 - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
2766 - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
2767 - `event_bus_clear`: Clear the event bus capture buffer.",
2768 annotations(
2769 read_only_hint = true,
2770 destructive_hint = false,
2771 idempotent_hint = true,
2772 open_world_hint = false
2773 )
2774 )]
2775 async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
2776 if !self.state.privacy.is_tool_enabled("introspect") {
2777 return tool_disabled("introspect");
2778 }
2779
2780 match params.action {
2781 IntrospectAction::CommandTimings => {
2782 let mut stats = self.state.command_timings.all_stats();
2783 if let Some(threshold) = params.slow_threshold_ms {
2784 stats.retain(|s| s.avg_ms >= threshold);
2785 }
2786 let result = serde_json::json!({
2787 "commands": stats,
2788 "total_commands_profiled": self.state.command_timings.all_stats().len(),
2789 "slow_threshold_ms": params.slow_threshold_ms,
2790 });
2791 json_result(&result)
2792 }
2793 IntrospectAction::Coverage => {
2794 let registered: Vec<String> = self
2795 .state
2796 .registry
2797 .list()
2798 .iter()
2799 .map(|c| c.name.clone())
2800 .collect();
2801
2802 let code = "return window.__VICTAURI__?.getIpcLog()";
2803 let invoked: std::collections::HashSet<String> = match self
2804 .eval_with_return(code, params.webview_label.as_deref())
2805 .await
2806 {
2807 Ok(json_str) => {
2808 if let Ok(entries) =
2809 serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2810 {
2811 entries
2812 .iter()
2813 .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
2814 .map(String::from)
2815 .collect()
2816 } else {
2817 std::collections::HashSet::new()
2818 }
2819 }
2820 Err(_) => std::collections::HashSet::new(),
2821 };
2822
2823 let uncovered: Vec<&String> = registered
2824 .iter()
2825 .filter(|cmd| !invoked.contains(cmd.as_str()))
2826 .collect();
2827
2828 let coverage_pct = if registered.is_empty() {
2829 100.0
2830 } else {
2831 let covered = registered.len() - uncovered.len();
2832 (covered as f64 / registered.len() as f64) * 100.0
2833 };
2834
2835 let result = serde_json::json!({
2836 "registered_commands": registered.len(),
2837 "invoked_commands": invoked.len(),
2838 "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2839 "uncovered": uncovered,
2840 "invoked_not_registered": invoked.iter()
2841 .filter(|cmd| !registered.contains(cmd))
2842 .collect::<Vec<_>>(),
2843 });
2844 json_result(&result)
2845 }
2846 IntrospectAction::ContractRecord => {
2847 let Some(command) = params.command else {
2848 return missing_param("command", "contract_record");
2849 };
2850 if !self.state.privacy.is_invoke_allowed(&command)
2853 || !self.state.privacy.is_command_allowed(&command)
2854 {
2855 return tool_error(format!(
2856 "command '{command}' is blocked by privacy configuration"
2857 ));
2858 }
2859 let args_json = params.args.unwrap_or(serde_json::json!({}));
2860 let args_str =
2861 serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
2862 let code = format!(
2863 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2864 js_string(&command)
2865 );
2866 match self
2867 .eval_with_return(&code, params.webview_label.as_deref())
2868 .await
2869 {
2870 Ok(result_str) => {
2871 let value: serde_json::Value = serde_json::from_str(&result_str)
2872 .unwrap_or(serde_json::Value::String(result_str.clone()));
2873 let shape = crate::introspection::JsonShape::from_value(&value);
2874 let sample = if result_str.len() > 4096 {
2875 format!("{}...(truncated)", &result_str[..4096])
2876 } else {
2877 result_str
2878 };
2879 let baseline = crate::introspection::ContractBaseline {
2880 command: command.clone(),
2881 args: args_json,
2882 shape: shape.clone(),
2883 sample,
2884 recorded_at: chrono_now(),
2885 };
2886 self.state.contract_store.record(baseline);
2887 let result = serde_json::json!({
2888 "recorded": true,
2889 "command": command,
2890 "shape_type": shape.type_name(),
2891 });
2892 json_result(&result)
2893 }
2894 Err(e) => tool_error(format!(
2895 "failed to invoke '{command}' for contract recording: {e}"
2896 )),
2897 }
2898 }
2899 IntrospectAction::ContractCheck => {
2900 let baselines = self.state.contract_store.all();
2901 if baselines.is_empty() {
2902 return json_result(&serde_json::json!({
2903 "checked": 0,
2904 "message": "no contract baselines recorded — use contract_record first",
2905 }));
2906 }
2907 let mut results = Vec::new();
2908 for baseline in &baselines {
2909 if !self.state.privacy.is_invoke_allowed(&baseline.command)
2912 || !self.state.privacy.is_command_allowed(&baseline.command)
2913 {
2914 continue;
2915 }
2916 let args_str =
2917 serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2918 let code = format!(
2919 "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2920 js_string(&baseline.command)
2921 );
2922 match self
2923 .eval_with_return(&code, params.webview_label.as_deref())
2924 .await
2925 {
2926 Ok(result_str) => {
2927 let value: serde_json::Value = serde_json::from_str(&result_str)
2928 .unwrap_or(serde_json::Value::String(result_str));
2929 let current_shape = crate::introspection::JsonShape::from_value(&value);
2930 let drift = crate::introspection::diff_shapes(
2931 &baseline.shape,
2932 ¤t_shape,
2933 &baseline.command,
2934 );
2935 results.push(drift);
2936 }
2937 Err(e) => {
2938 results.push(crate::introspection::ContractDrift {
2939 command: baseline.command.clone(),
2940 new_fields: Vec::new(),
2941 removed_fields: Vec::new(),
2942 type_changes: Vec::new(),
2943 shape_matches: false,
2944 });
2945 tracing::warn!(
2946 command = %baseline.command,
2947 error = %e,
2948 "contract check invocation failed"
2949 );
2950 }
2951 }
2952 }
2953 let passing = results.iter().filter(|r| r.shape_matches).count();
2954 let result = serde_json::json!({
2955 "checked": results.len(),
2956 "passing": passing,
2957 "failing": results.len() - passing,
2958 "contracts": results,
2959 });
2960 json_result(&result)
2961 }
2962 IntrospectAction::ContractList => {
2963 let baselines = self.state.contract_store.all();
2964 let result = serde_json::json!({
2965 "count": baselines.len(),
2966 "baselines": baselines.iter().map(|b| serde_json::json!({
2967 "command": b.command,
2968 "shape_type": b.shape.type_name(),
2969 "recorded_at": b.recorded_at,
2970 })).collect::<Vec<_>>(),
2971 });
2972 json_result(&result)
2973 }
2974 IntrospectAction::ContractClear => {
2975 let cleared = self.state.contract_store.clear();
2976 json_result(&serde_json::json!({
2977 "cleared": cleared,
2978 }))
2979 }
2980 IntrospectAction::StartupTiming => {
2981 let phases = self.state.startup_timeline.report();
2982 let result = serde_json::json!({
2983 "phases": phases,
2984 "total_ms": self.state.startup_timeline.total_ms(),
2985 "uptime_secs": self.state.started_at.elapsed().as_secs(),
2986 });
2987 json_result(&result)
2988 }
2989 IntrospectAction::Capabilities => {
2990 let config = self.bridge.tauri_config();
2991 let live_windows = self.bridge.list_window_labels();
2992
2993 let result = serde_json::json!({
2994 "app": {
2995 "identifier": config.get("identifier"),
2996 "product_name": config.get("product_name"),
2997 "version": config.get("version"),
2998 },
2999 "security": config.get("security"),
3000 "configured_windows": config.get("windows"),
3001 "live_windows": live_windows,
3002 "configured_plugins": config.get("plugins"),
3003 "victauri": {
3004 "registered_commands": self.state.registry.list().len(),
3005 "redaction_enabled": self.state.privacy.redaction_enabled,
3006 "privacy_profile": format!("{:?}", self.state.privacy.profile),
3007 "disabled_tools": &self.state.privacy.disabled_tools,
3008 },
3009 });
3010 json_result(&result)
3011 }
3012 #[allow(unused_variables)]
3013 IntrospectAction::DbHealth => {
3014 #[cfg(feature = "sqlite")]
3015 {
3016 let db_path = params.db_path.clone();
3017 match self.run_db_health(db_path.as_deref()).await {
3018 Ok(health) => json_result(&health),
3019 Err(e) => tool_error(format!("db_health failed: {e}")),
3020 }
3021 }
3022 #[cfg(not(feature = "sqlite"))]
3023 {
3024 tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3025 }
3026 }
3027 IntrospectAction::PluginState => {
3028 let recording_active = self.state.recorder.is_recording();
3029 let recording_events = self.state.recorder.event_count();
3030 let result = serde_json::json!({
3031 "event_log": {
3032 "size": self.state.event_log.len(),
3033 "capacity": self.state.event_log.capacity(),
3034 },
3035 "registry": {
3036 "commands_registered": self.state.registry.list().len(),
3037 },
3038 "recording": {
3039 "active": recording_active,
3040 "events_captured": recording_events,
3041 },
3042 "faults": {
3043 "active_rules": self.state.fault_registry.list().len(),
3044 },
3045 "contracts": {
3046 "baselines_recorded": self.state.contract_store.all().len(),
3047 },
3048 "timings": {
3049 "commands_profiled": self.state.command_timings.all_stats().len(),
3050 },
3051 "event_bus": {
3052 "captured_events": self.state.event_bus.len(),
3053 },
3054 "tasks": {
3055 "total": self.state.task_tracker.list().len(),
3056 "active": self.state.task_tracker.active_count(),
3057 },
3058 "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3059 "uptime_secs": self.state.started_at.elapsed().as_secs(),
3060 "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3061 });
3062 json_result(&result)
3063 }
3064 IntrospectAction::Processes => {
3065 let pid = std::process::id();
3066 let uptime = self.state.started_at.elapsed();
3067 let children = crate::introspection::enumerate_child_processes();
3068 let host_memory = crate::memory::current_stats();
3069
3070 let result = serde_json::json!({
3071 "host": {
3072 "pid": pid,
3073 "uptime_secs": uptime.as_secs(),
3074 "platform": std::env::consts::OS,
3075 "arch": std::env::consts::ARCH,
3076 "memory": host_memory,
3077 },
3078 "children": children.iter().map(|c| serde_json::json!({
3079 "pid": c.pid,
3080 "name": c.name,
3081 "memory_bytes": c.memory_bytes,
3082 })).collect::<Vec<_>>(),
3083 "child_count": children.len(),
3084 "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3085 });
3086 json_result(&result)
3087 }
3088 IntrospectAction::PluginTasks => {
3089 let tasks = self.state.task_tracker.list();
3090 let active = self.state.task_tracker.active_count();
3091 let result = serde_json::json!({
3092 "total": tasks.len(),
3093 "active": active,
3094 "finished": tasks.len() - active,
3095 "tasks": tasks,
3096 });
3097 json_result(&result)
3098 }
3099 IntrospectAction::EventBus => {
3100 let tauri_events = self.state.event_bus.events();
3101 let app_events = self.state.event_log.snapshot();
3102 let result = serde_json::json!({
3103 "tauri_events": {
3104 "count": tauri_events.len(),
3105 "events": tauri_events,
3106 },
3107 "app_events": {
3108 "count": app_events.len(),
3109 "capacity": self.state.event_log.capacity(),
3110 "events": app_events,
3111 },
3112 });
3113 json_result(&result)
3114 }
3115 IntrospectAction::EventBusClear => {
3116 let tauri_cleared = self.state.event_bus.clear();
3117 self.state.event_log.clear();
3118 json_result(&serde_json::json!({
3119 "tauri_events_cleared": tauri_cleared,
3120 "app_events_cleared": true,
3121 }))
3122 }
3123 }
3124 }
3125
3126 #[tool(
3129 description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3130 Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3131 SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3132 they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), \
3133 which runs below the layer Victauri can reach. Use this to test a handler's error path when \
3134 YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\
3135 Actions:\n\
3136 - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
3137 - `list`: List all active fault injection rules.\n\
3138 - `clear`: Remove a specific fault rule (requires `command`).\n\
3139 - `clear_all`: Remove all fault rules.",
3140 annotations(
3141 read_only_hint = false,
3142 destructive_hint = true,
3143 idempotent_hint = false,
3144 open_world_hint = false
3145 )
3146 )]
3147 async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
3148 if !self.state.privacy.is_tool_enabled("fault") {
3149 return tool_disabled("fault");
3150 }
3151
3152 match params.action {
3153 FaultAction::Inject => {
3154 let Some(command) = params.command else {
3155 return missing_param("command", "inject");
3156 };
3157 let Some(fault_kind) = params.fault_type else {
3158 return missing_param("fault_type", "inject");
3159 };
3160 let fault_type = match fault_kind {
3161 FaultKind::Delay => {
3162 let delay_ms = params.delay_ms.unwrap_or(1000);
3163 crate::introspection::FaultType::Delay { delay_ms }
3164 }
3165 FaultKind::Error => {
3166 let message = params
3167 .error_message
3168 .unwrap_or_else(|| "injected fault".to_string());
3169 crate::introspection::FaultType::Error { message }
3170 }
3171 FaultKind::Drop => crate::introspection::FaultType::Drop,
3172 FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
3173 };
3174 let config = crate::introspection::FaultConfig {
3175 command: command.clone(),
3176 fault_type: fault_type.clone(),
3177 trigger_count: 0,
3178 max_triggers: params.max_triggers.unwrap_or(0),
3179 created_at: std::time::Instant::now(),
3180 };
3181 self.state.fault_registry.inject(config);
3182 let result = serde_json::json!({
3183 "injected": true,
3184 "command": command,
3185 "fault_type": fault_type,
3186 "max_triggers": params.max_triggers.unwrap_or(0),
3187 });
3188 json_result(&result)
3189 }
3190 FaultAction::List => {
3191 let faults = self.state.fault_registry.list();
3192 let result = serde_json::json!({
3193 "count": faults.len(),
3194 "faults": faults.iter().map(|f| serde_json::json!({
3195 "command": f.command,
3196 "fault_type": f.fault_type,
3197 "trigger_count": f.trigger_count,
3198 "max_triggers": f.max_triggers,
3199 })).collect::<Vec<_>>(),
3200 });
3201 json_result(&result)
3202 }
3203 FaultAction::Clear => {
3204 let Some(command) = params.command else {
3205 return missing_param("command", "clear");
3206 };
3207 let removed = self.state.fault_registry.clear(&command);
3208 json_result(&serde_json::json!({
3209 "removed": removed,
3210 "command": command,
3211 }))
3212 }
3213 FaultAction::ClearAll => {
3214 let removed = self.state.fault_registry.clear_all();
3215 json_result(&serde_json::json!({
3216 "removed": removed,
3217 }))
3218 }
3219 }
3220 }
3221
3222 #[tool(
3225 description = "Correlate recent activity across all layers into a coherent narrative. \
3226 CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
3227 + window events across the Rust backend and webview simultaneously.\n\n\
3228 Actions:\n\
3229 - `summary`: High-level activity summary for the last N seconds (default 30). \
3230 Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
3231 - `last_action`: Correlate the most recent burst of events into a causal timeline \
3232 (e.g. 'IPC call → DOM update → console.log').\n\
3233 - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
3234 annotations(
3235 read_only_hint = true,
3236 destructive_hint = false,
3237 idempotent_hint = true,
3238 open_world_hint = false
3239 )
3240 )]
3241 async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
3242 if !self.state.privacy.is_tool_enabled("explain") {
3243 return tool_disabled("explain");
3244 }
3245
3246 match params.action {
3247 ExplainAction::Summary => {
3248 let secs = params.seconds.unwrap_or(30);
3249 let since = chrono::Utc::now()
3250 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3251 let events = self.state.event_log.since(since);
3252
3253 let mut ipc_count = 0u64;
3254 let mut dom_mutations = 0u64;
3255 let mut state_changes = 0u64;
3256 let mut console_count = 0u64;
3257 let mut window_events = 0u64;
3258 let mut interactions = 0u64;
3259 let mut top_commands: HashMap<String, u64> = HashMap::new();
3260 let mut errors: Vec<String> = Vec::new();
3261
3262 for event in &events {
3263 match event {
3264 victauri_core::AppEvent::Ipc(call) => {
3265 ipc_count += 1;
3266 *top_commands.entry(call.command.clone()).or_insert(0) += 1;
3267 if let victauri_core::IpcResult::Err(e) = &call.result {
3268 errors.push(format!("IPC {}: {e}", call.command));
3269 }
3270 }
3271 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3272 dom_mutations += u64::from(*mutation_count)
3273 }
3274 victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
3275 victauri_core::AppEvent::Console { level, message, .. } => {
3276 console_count += 1;
3277 if level == "error" {
3278 errors.push(format!("console.error: {message}"));
3279 }
3280 }
3281 victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
3282 victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
3283 _ => {}
3284 }
3285 }
3286
3287 let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
3288 sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
3289 let top: Vec<_> = sorted_cmds.iter().take(5).collect();
3290
3291 let narrative = format!(
3292 "{ipc_count} IPC call{} in the last {secs}s{}. \
3293 {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
3294 {console_count} console message{}, {window_events} window event{}. {}.",
3295 if ipc_count == 1 { "" } else { "s" },
3296 if top.is_empty() {
3297 String::new()
3298 } else {
3299 format!(
3300 ", dominated by {}",
3301 top.iter()
3302 .map(|(cmd, n)| format!("{cmd} ({n}x)"))
3303 .collect::<Vec<_>>()
3304 .join(", ")
3305 )
3306 },
3307 if dom_mutations == 1 { "" } else { "s" },
3308 if interactions == 1 { "" } else { "s" },
3309 if console_count == 1 { "" } else { "s" },
3310 if window_events == 1 { "" } else { "s" },
3311 if errors.is_empty() {
3312 "No errors".to_string()
3313 } else {
3314 format!(
3315 "{} error{}",
3316 errors.len(),
3317 if errors.len() == 1 { "" } else { "s" }
3318 )
3319 },
3320 );
3321
3322 let result = serde_json::json!({
3323 "time_window_secs": secs,
3324 "total_events": events.len(),
3325 "ipc_calls": ipc_count,
3326 "dom_mutations": dom_mutations,
3327 "state_changes": state_changes,
3328 "console_messages": console_count,
3329 "window_events": window_events,
3330 "interactions": interactions,
3331 "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
3332 serde_json::json!({"command": cmd, "count": n})
3333 }).collect::<Vec<_>>(),
3334 "errors": errors,
3335 "narrative": narrative,
3336 });
3337 json_result(&result)
3338 }
3339 ExplainAction::LastAction => {
3340 let secs = params.seconds.unwrap_or(5);
3341 let since = chrono::Utc::now()
3342 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3343 let events = self.state.event_log.since(since);
3344
3345 let timeline: Vec<serde_json::Value> = events
3346 .iter()
3347 .filter(|e| !e.is_internal())
3348 .map(|event| match event {
3349 victauri_core::AppEvent::Ipc(call) => serde_json::json!({
3350 "time": call.timestamp.to_rfc3339_opts(
3351 chrono::SecondsFormat::Millis, true
3352 ),
3353 "type": "ipc",
3354 "detail": format!(
3355 "{} {} ({}ms)",
3356 call.command,
3357 call.result,
3358 call.duration_ms.unwrap_or(0)
3359 ),
3360 }),
3361 victauri_core::AppEvent::DomMutation {
3362 timestamp,
3363 mutation_count,
3364 webview_label,
3365 } => serde_json::json!({
3366 "time": timestamp.to_rfc3339_opts(
3367 chrono::SecondsFormat::Millis, true
3368 ),
3369 "type": "dom_mutation",
3370 "detail": format!(
3371 "{mutation_count} element{} updated in {webview_label}",
3372 if *mutation_count == 1 { "" } else { "s" }
3373 ),
3374 }),
3375 victauri_core::AppEvent::DomInteraction {
3376 timestamp,
3377 action,
3378 selector,
3379 ..
3380 } => serde_json::json!({
3381 "time": timestamp.to_rfc3339_opts(
3382 chrono::SecondsFormat::Millis, true
3383 ),
3384 "type": "interaction",
3385 "detail": format!("{action} on {selector}"),
3386 }),
3387 victauri_core::AppEvent::StateChange {
3388 timestamp,
3389 key,
3390 caused_by,
3391 } => serde_json::json!({
3392 "time": timestamp.to_rfc3339_opts(
3393 chrono::SecondsFormat::Millis, true
3394 ),
3395 "type": "state_change",
3396 "detail": format!(
3397 "{key} changed{}",
3398 caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
3399 ),
3400 }),
3401 victauri_core::AppEvent::Console {
3402 timestamp,
3403 level,
3404 message,
3405 } => serde_json::json!({
3406 "time": timestamp.to_rfc3339_opts(
3407 chrono::SecondsFormat::Millis, true
3408 ),
3409 "type": "console",
3410 "detail": format!("console.{level}: {message}"),
3411 }),
3412 victauri_core::AppEvent::WindowEvent {
3413 timestamp,
3414 label,
3415 event,
3416 } => serde_json::json!({
3417 "time": timestamp.to_rfc3339_opts(
3418 chrono::SecondsFormat::Millis, true
3419 ),
3420 "type": "window_event",
3421 "detail": format!("{event} on window '{label}'"),
3422 }),
3423 _ => serde_json::json!({
3424 "time": event.timestamp().to_rfc3339_opts(
3425 chrono::SecondsFormat::Millis, true
3426 ),
3427 "type": "other",
3428 "detail": "unknown event type",
3429 }),
3430 })
3431 .collect();
3432
3433 let narrative = if timeline.is_empty() {
3434 format!("No activity in the last {secs}s.")
3435 } else {
3436 let parts: Vec<String> = timeline
3437 .iter()
3438 .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
3439 .map(String::from)
3440 .collect();
3441 parts.join(" → ")
3442 };
3443
3444 let result = serde_json::json!({
3445 "time_window_secs": secs,
3446 "event_count": timeline.len(),
3447 "timeline": timeline,
3448 "narrative": narrative,
3449 });
3450 json_result(&result)
3451 }
3452 ExplainAction::Diff => {
3453 let secs = params.seconds.unwrap_or(10);
3454 let since = chrono::Utc::now()
3455 - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3456 let events = self.state.event_log.since(since);
3457
3458 let mut ipc_commands: Vec<String> = Vec::new();
3459 let mut dom_changes = 0u64;
3460 let mut error_count = 0u64;
3461 let mut interaction_count = 0u64;
3462 let mut console_messages = 0u64;
3463
3464 for event in &events {
3465 if event.is_internal() {
3466 continue;
3467 }
3468 match event {
3469 victauri_core::AppEvent::Ipc(call) => {
3470 ipc_commands.push(call.command.clone());
3471 if matches!(call.result, victauri_core::IpcResult::Err(_)) {
3472 error_count += 1;
3473 }
3474 }
3475 victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3476 dom_changes += u64::from(*mutation_count)
3477 }
3478 victauri_core::AppEvent::DomInteraction { .. } => {
3479 interaction_count += 1;
3480 }
3481 victauri_core::AppEvent::Console { level, .. } => {
3482 console_messages += 1;
3483 if level == "error" {
3484 error_count += 1;
3485 }
3486 }
3487 _ => {}
3488 }
3489 }
3490
3491 ipc_commands.dedup();
3492
3493 let result = serde_json::json!({
3494 "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3495 "time_window_secs": secs,
3496 "total_events": events.len(),
3497 "ipc_calls_made": ipc_commands.len(),
3498 "unique_commands": ipc_commands,
3499 "dom_elements_changed": dom_changes,
3500 "interactions": interaction_count,
3501 "console_messages": console_messages,
3502 "errors": error_count,
3503 });
3504 json_result(&result)
3505 }
3506 }
3507 }
3508}
3509
3510impl VictauriMcpHandler {
3511 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
3513 Self {
3514 state,
3515 bridge,
3516 subscriptions: Arc::new(Mutex::new(HashSet::new())),
3517 bridge_checked: Arc::new(AtomicBool::new(false)),
3518 probed_labels: Arc::new(Mutex::new(HashSet::new())),
3519 timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
3520 }
3521 }
3522
3523 pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
3524 self.state.privacy.is_tool_enabled(name)
3525 }
3526
3527 pub(crate) async fn execute_tool(
3528 &self,
3529 name: &str,
3530 args: serde_json::Value,
3531 ) -> Result<CallToolResult, rest::ToolCallError> {
3532 if !self.state.privacy.is_tool_enabled(name) {
3533 return Ok(tool_disabled(name));
3534 }
3535 self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3536 let start = std::time::Instant::now();
3537 tracing::debug!(tool = %name, "REST tool invocation started");
3538
3539 let result = match name {
3540 "eval_js" => {
3541 let p: EvalJsParams = Self::parse_args(args)?;
3542 self.eval_js(Parameters(p)).await
3543 }
3544 "dom_snapshot" => {
3545 let p: SnapshotParams = Self::parse_args(args)?;
3546 self.dom_snapshot(Parameters(p)).await
3547 }
3548 "find_elements" => {
3549 let p: FindElementsParams = Self::parse_args(args)?;
3550 self.find_elements(Parameters(p)).await
3551 }
3552 "invoke_command" => {
3553 let p: InvokeCommandParams = Self::parse_args(args)?;
3554 self.invoke_command(Parameters(p)).await
3555 }
3556 "screenshot" => {
3557 let p: ScreenshotParams = Self::parse_args(args)?;
3558 self.screenshot(Parameters(p)).await
3559 }
3560 "verify_state" => {
3561 let p: VerifyStateParams = Self::parse_args(args)?;
3562 self.verify_state(Parameters(p)).await
3563 }
3564 "detect_ghost_commands" => {
3565 let p: GhostCommandParams = Self::parse_args(args)?;
3566 self.detect_ghost_commands(Parameters(p)).await
3567 }
3568 "check_ipc_integrity" => {
3569 let p: IpcIntegrityParams = Self::parse_args(args)?;
3570 self.check_ipc_integrity(Parameters(p)).await
3571 }
3572 "wait_for" => {
3573 let p: WaitForParams = Self::parse_args(args)?;
3574 self.wait_for(Parameters(p)).await
3575 }
3576 "assert_semantic" => {
3577 let p: SemanticAssertParams = Self::parse_args(args)?;
3578 self.assert_semantic(Parameters(p)).await
3579 }
3580 "resolve_command" => {
3581 let p: ResolveCommandParams = Self::parse_args(args)?;
3582 self.resolve_command(Parameters(p)).await
3583 }
3584 "get_registry" => {
3585 let p: RegistryParams = Self::parse_args(args)?;
3586 self.get_registry(Parameters(p)).await
3587 }
3588 "app_state" => {
3589 let p: AppStateParams = Self::parse_args(args)?;
3590 self.app_state(Parameters(p)).await
3591 }
3592 "get_memory_stats" => self.get_memory_stats().await,
3593 "get_plugin_info" => self.get_plugin_info().await,
3594 "get_diagnostics" => {
3595 let p: DiagnosticsParams = Self::parse_args(args)?;
3596 self.get_diagnostics(Parameters(p)).await
3597 }
3598 "app_info" => self.app_info().await,
3599 "list_app_dir" => {
3600 let p: ListAppDirParams = Self::parse_args(args)?;
3601 self.list_app_dir(Parameters(p)).await
3602 }
3603 "read_app_file" => {
3604 let p: ReadAppFileParams = Self::parse_args(args)?;
3605 self.read_app_file(Parameters(p)).await
3606 }
3607 "query_db" => {
3608 let p: QueryDbParams = Self::parse_args(args)?;
3609 self.query_db(Parameters(p)).await
3610 }
3611 "interact" => {
3612 let p: InteractParams = Self::parse_args(args)?;
3613 self.interact(Parameters(p)).await
3614 }
3615 "input" => {
3616 let p: InputParams = Self::parse_args(args)?;
3617 self.input(Parameters(p)).await
3618 }
3619 "window" => {
3620 let p: WindowParams = Self::parse_args(args)?;
3621 self.window(Parameters(p)).await
3622 }
3623 "storage" => {
3624 let p: StorageParams = Self::parse_args(args)?;
3625 self.storage(Parameters(p)).await
3626 }
3627 "navigate" => {
3628 let p: NavigateParams = Self::parse_args(args)?;
3629 self.navigate(Parameters(p)).await
3630 }
3631 "recording" => {
3632 let p: RecordingParams = Self::parse_args(args)?;
3633 self.recording(Parameters(p)).await
3634 }
3635 "inspect" => {
3636 let p: InspectParams = Self::parse_args(args)?;
3637 self.inspect(Parameters(p)).await
3638 }
3639 "css" => {
3640 let p: CssParams = Self::parse_args(args)?;
3641 self.css(Parameters(p)).await
3642 }
3643 "route" => {
3644 let p: RouteParams = Self::parse_args(args)?;
3645 self.route(Parameters(p)).await
3646 }
3647 "trace" => {
3648 let p: TraceParams = Self::parse_args(args)?;
3649 self.trace(Parameters(p)).await
3650 }
3651 "animation" => {
3652 let p: AnimationParams = Self::parse_args(args)?;
3653 self.animation(Parameters(p)).await
3654 }
3655 "logs" => {
3656 let p: LogsParams = Self::parse_args(args)?;
3657 self.logs(Parameters(p)).await
3658 }
3659 "introspect" => {
3660 let p: IntrospectParams = Self::parse_args(args)?;
3661 self.introspect(Parameters(p)).await
3662 }
3663 "fault" => {
3664 let p: FaultParams = Self::parse_args(args)?;
3665 self.fault(Parameters(p)).await
3666 }
3667 "explain" => {
3668 let p: ExplainParams = Self::parse_args(args)?;
3669 self.explain(Parameters(p)).await
3670 }
3671 _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3672 };
3673
3674 let elapsed = start.elapsed();
3675 tracing::debug!(
3676 tool = %name,
3677 elapsed_ms = elapsed.as_millis() as u64,
3678 "REST tool invocation completed"
3679 );
3680
3681 if self.state.privacy.redaction_enabled {
3682 Ok(Self::redact_result(result, &self.state.privacy))
3683 } else {
3684 Ok(result)
3685 }
3686 }
3687
3688 fn parse_args<T: serde::de::DeserializeOwned>(
3689 args: serde_json::Value,
3690 ) -> Result<T, rest::ToolCallError> {
3691 serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3692 }
3693
3694 fn redact_result(
3695 mut result: CallToolResult,
3696 privacy: &crate::privacy::PrivacyConfig,
3697 ) -> CallToolResult {
3698 for item in &mut result.content {
3699 if let RawContent::Text(ref mut tc) = item.raw {
3700 tc.text = privacy.redact_output(&tc.text);
3701 }
3702 }
3703 result
3704 }
3705
3706 fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3707 match dir.unwrap_or(AppDir::Data) {
3708 AppDir::Data => self.bridge.app_data_dir(),
3709 AppDir::Config => self.bridge.app_config_dir(),
3710 AppDir::Log => self.bridge.app_log_dir(),
3711 AppDir::LocalData => self.bridge.app_local_data_dir(),
3712 }
3713 }
3714
3715 fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
3723 use std::path::Component;
3724 if sub.is_absolute() {
3725 return Err("path traversal not allowed: absolute paths are rejected".to_string());
3726 }
3727 for component in sub.components() {
3728 match component {
3729 Component::ParentDir => {
3730 return Err("path traversal not allowed: '..' is rejected".to_string());
3731 }
3732 Component::Prefix(_) | Component::RootDir => {
3733 return Err(
3734 "path traversal not allowed: absolute paths are rejected".to_string()
3735 );
3736 }
3737 Component::CurDir | Component::Normal(_) => {}
3738 }
3739 }
3740 Ok(())
3741 }
3742
3743 fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
3744 let canon_base = std::fs::canonicalize(base)
3745 .map_err(|e| format!("cannot resolve base directory: {e}"))?;
3746 let canon_target = std::fs::canonicalize(target)
3747 .map_err(|e| format!("cannot resolve target path: {e}"))?;
3748 if !canon_target.starts_with(&canon_base) {
3749 return Err("path traversal not allowed".to_string());
3750 }
3751 Ok(())
3752 }
3753
3754 fn list_dir_recursive(
3755 dir: &std::path::Path,
3756 base: &std::path::Path,
3757 depth: u32,
3758 max_depth: u32,
3759 pattern: Option<&str>,
3760 entries: &mut Vec<serde_json::Value>,
3761 ) {
3762 let Ok(read_dir) = std::fs::read_dir(dir) else {
3763 return;
3764 };
3765 for entry in read_dir.flatten() {
3766 let path = entry.path();
3767 if path.is_symlink() {
3768 continue;
3769 }
3770 let name = entry.file_name().to_string_lossy().into_owned();
3771 let relative = path
3772 .strip_prefix(base)
3773 .unwrap_or(&path)
3774 .to_string_lossy()
3775 .into_owned();
3776
3777 if let Some(pat) = pattern
3778 && !Self::matches_glob(&name, pat)
3779 && !path.is_dir()
3780 {
3781 continue;
3782 }
3783
3784 let is_dir = path.is_dir();
3785 let meta = std::fs::metadata(&path).ok();
3786
3787 entries.push(serde_json::json!({
3788 "name": name,
3789 "path": relative,
3790 "is_dir": is_dir,
3791 "size": meta.as_ref().map(std::fs::Metadata::len),
3792 "modified": meta.as_ref()
3793 .and_then(|m| m.modified().ok())
3794 .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
3795 .unwrap_or_default().as_secs()),
3796 }));
3797
3798 if is_dir && depth < max_depth {
3799 Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
3800 }
3801 }
3802 }
3803
3804 fn matches_glob(name: &str, pattern: &str) -> bool {
3805 if pattern == "*" {
3806 return true;
3807 }
3808 if let Some(suffix) = pattern.strip_prefix("*.") {
3809 return name.ends_with(&format!(".{suffix}"));
3810 }
3811 if let Some(prefix) = pattern.strip_suffix("*") {
3812 return name.starts_with(prefix);
3813 }
3814 name == pattern
3815 }
3816
3817 async fn window_introspectability(&self) -> CallToolResult {
3823 let labels = self.bridge.list_window_labels();
3824 let states = self.bridge.get_window_states(None);
3825 let mut report = Vec::with_capacity(labels.len());
3826 let mut blind = 0usize;
3827 for label in &labels {
3828 let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
3829 let introspectable = self.probe_bridge(Some(label)).await.is_ok();
3830 if !introspectable {
3831 blind += 1;
3832 }
3833 let note = if introspectable {
3834 "ok — Victauri JS bridge is responding".to_string()
3835 } else if visible == Some(true) {
3836 format!(
3837 "NOT introspectable although the window is visible — almost certainly missing \
3838 the Victauri capability. Add \"victauri:default\" to the capability file \
3839 (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
3840 then rebuild. Capabilities are baked at compile time, so a rebuild is required."
3841 )
3842 } else {
3843 "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
3844 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
3845 .to_string()
3846 };
3847 report.push(serde_json::json!({
3848 "label": label,
3849 "visible": visible,
3850 "introspectable": introspectable,
3851 "note": note,
3852 }));
3853 }
3854 let hint = if blind > 0 {
3855 "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
3856 dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
3857 missing \"victauri:default\" capability for that window: Tauri's per-window permission \
3858 ACL silently blocks the bridge's callback IPC. This capability is required per window, \
3859 not just for the main window. (Note: probing a blind window takes ~2s each.)"
3860 } else {
3861 "All windows are introspectable."
3862 };
3863 json_result(&serde_json::json!({
3864 "windows": report,
3865 "introspectable_count": labels.len().saturating_sub(blind),
3866 "blind_count": blind,
3867 "hint": hint,
3868 }))
3869 }
3870
3871 async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
3872 match self.eval_with_return(code, webview_label).await {
3873 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
3874 Err(e) => tool_error(e),
3875 }
3876 }
3877
3878 async fn eval_with_return(
3879 &self,
3880 code: &str,
3881 webview_label: Option<&str>,
3882 ) -> Result<String, String> {
3883 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
3884 .await
3885 }
3886
3887 async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
3888 let id = uuid::Uuid::new_v4().to_string();
3889 let (tx, rx) = tokio::sync::oneshot::channel();
3890 {
3891 let mut pending = self.state.pending_evals.lock().await;
3892 pending.insert(id.clone(), tx);
3893 }
3894 let id_js = js_string(&id);
3895 let probe = format!(
3896 r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
3897 );
3898 if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
3899 self.state.pending_evals.lock().await.remove(&id);
3900 return Err(format!("eval injection failed: {e}"));
3901 }
3902 if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
3903 Ok(())
3904 } else {
3905 self.state.pending_evals.lock().await.remove(&id);
3906 let label = webview_label.unwrap_or("default");
3907 Err(format!(
3908 "bridge not responding on window '{label}' — the window may be hidden, \
3909 missing the victauri capability, or the JS bridge is not loaded"
3910 ))
3911 }
3912 }
3913
3914 async fn eval_with_return_timeout(
3915 &self,
3916 code: &str,
3917 webview_label: Option<&str>,
3918 timeout: std::time::Duration,
3919 ) -> Result<String, String> {
3920 if !self
3925 .state
3926 .bridge_ready
3927 .load(std::sync::atomic::Ordering::Acquire)
3928 {
3929 let notified = self.state.bridge_notify.notified();
3930 if !self
3931 .state
3932 .bridge_ready
3933 .load(std::sync::atomic::Ordering::Acquire)
3934 {
3935 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
3936 }
3937 }
3938
3939 let label_key =
3942 webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
3943
3944 if webview_label.is_some() {
3947 let already_probed = self.probed_labels.lock().await.contains(&label_key);
3948 if !already_probed {
3949 self.probe_bridge(webview_label).await?;
3950 self.probed_labels.lock().await.insert(label_key.clone());
3951 }
3952 }
3953
3954 if self.timed_out_labels.lock().await.remove(&label_key) {
3961 self.probe_bridge(webview_label).await.map_err(|e| {
3962 format!("{e} (previous eval on this window timed out; the webview may have reloaded or the app stopped responding)")
3963 })?;
3964 }
3965
3966 let id = uuid::Uuid::new_v4().to_string();
3967 let (tx, rx) = tokio::sync::oneshot::channel();
3968
3969 {
3970 let mut pending = self.state.pending_evals.lock().await;
3971 if pending.len() >= MAX_PENDING_EVALS {
3972 return Err(format!(
3973 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
3974 ));
3975 }
3976 pending.insert(id.clone(), tx);
3977 }
3978
3979 let code = if should_prepend_return(code) {
3986 format!("return {}", code.trim())
3987 } else {
3988 code.trim().to_string()
3989 };
3990
3991 let id_js = js_string(&id);
3992
3993 let watchdog = format!(
4006 r"
4007 (function () {{
4008 window.__VIC_EVAL__ = window.__VIC_EVAL__ || {{}};
4009 var s = (window.__VIC_EVAL__[{id_js}] =
4010 window.__VIC_EVAL__[{id_js}] || {{ started: false, done: false }});
4011 setTimeout(function () {{
4012 if (s.started || s.done) return;
4013 s.done = true;
4014 try {{
4015 window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4016 id: {id_js},
4017 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)' }})
4018 }});
4019 }} catch (e) {{}}
4020 delete window.__VIC_EVAL__[{id_js}];
4021 }}, {PARSE_WATCHDOG_MS});
4022 }})();
4023 "
4024 );
4025
4026 let inject = format!(
4027 r"
4028 (async () => {{
4029 var __s = (window.__VIC_EVAL__ && window.__VIC_EVAL__[{id_js}]) || null;
4030 if (__s) __s.started = true;
4031 try {{
4032 const __result = await (async () => {{ {code} }})();
4033 if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4034 const __type = __result === undefined ? 'undefined'
4035 : __result === null ? 'null' : 'value';
4036 const __val = __type === 'undefined' ? null
4037 : __type === 'null' ? null : __result;
4038 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4039 id: {id_js},
4040 result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
4041 }});
4042 }} catch (e) {{
4043 if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4044 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4045 id: {id_js},
4046 result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
4047 }});
4048 }}
4049 }})();
4050 "
4051 );
4052
4053 if let Err(e) = self.bridge.eval_webview(webview_label, &watchdog) {
4057 self.state.pending_evals.lock().await.remove(&id);
4058 return Err(format!("eval injection failed: {e}"));
4059 }
4060 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
4061 self.state.pending_evals.lock().await.remove(&id);
4062 return Err(format!("eval injection failed: {e}"));
4063 }
4064
4065 match tokio::time::timeout(timeout, rx).await {
4066 Ok(Ok(raw)) => {
4067 self.check_bridge_version_once();
4068 if raw.len() > MAX_EVAL_RESULT_LEN {
4069 return Err(format!(
4070 "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
4071 raw.len()
4072 ));
4073 }
4074 unwrap_eval_envelope(raw)
4075 }
4076 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
4077 Err(_) => {
4078 self.state.pending_evals.lock().await.remove(&id);
4079 self.timed_out_labels.lock().await.insert(label_key.clone());
4083 Err(format!(
4084 "eval timed out after {}s — the code began executing but never resolved. \
4085 (A syntax/parse error would have failed fast via the parse watchdog, so \
4086 this is NOT a parse error.) Common causes: an unresolved promise, an \
4087 infinite loop, an `await` on something that never settles, or the webview \
4088 reloaded / the app stopped responding mid-eval. If the app may have \
4089 navigated or crashed, retry (the next call fails fast if the bridge is \
4090 gone).",
4091 timeout.as_secs()
4092 ))
4093 }
4094 }
4095 }
4096
4097 #[cfg(feature = "sqlite")]
4098 async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
4099 let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
4101 for d in [
4102 self.bridge.app_data_dir(),
4103 self.bridge.app_local_data_dir(),
4104 self.bridge.app_config_dir(),
4105 ]
4106 .into_iter()
4107 .flatten()
4108 {
4109 roots.push(d);
4110 }
4111
4112 let path = if let Some(p) = db_path {
4113 let candidate = std::path::Path::new(p);
4114 if candidate.is_absolute() {
4115 if !roots
4116 .iter()
4117 .any(|r| Self::safe_within(r, candidate).is_ok())
4118 {
4119 return Err(format!(
4120 "absolute path '{p}' is not within an allowed directory; \
4121 register its parent via VictauriBuilder::db_search_paths"
4122 ));
4123 }
4124 candidate.to_path_buf()
4125 } else {
4126 roots
4127 .iter()
4128 .map(|r| r.join(p))
4129 .find(|c| c.exists())
4130 .ok_or_else(|| format!("database not found: {p}"))?
4131 }
4132 } else {
4133 let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
4137 roots.clone()
4138 } else {
4139 self.state.db_search_paths.clone()
4140 };
4141 crate::database::select_app_database(&select_dirs)?
4142 };
4143 let path_str = path
4149 .to_str()
4150 .ok_or_else(|| "invalid path encoding".to_string())?
4151 .to_string();
4152
4153 tokio::task::spawn_blocking(move || {
4154 let conn = rusqlite::Connection::open_with_flags(
4155 &path_str,
4156 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
4157 )
4158 .map_err(|e| format!("cannot open database: {e}"))?;
4159
4160 let journal_mode: String = conn
4161 .pragma_query_value(None, "journal_mode", |r| r.get(0))
4162 .unwrap_or_else(|_| "unknown".to_string());
4163
4164 let page_count: i64 = conn
4165 .pragma_query_value(None, "page_count", |r| r.get(0))
4166 .unwrap_or(0);
4167
4168 let page_size: i64 = conn
4169 .pragma_query_value(None, "page_size", |r| r.get(0))
4170 .unwrap_or(0);
4171
4172 let freelist_count: i64 = conn
4173 .pragma_query_value(None, "freelist_count", |r| r.get(0))
4174 .unwrap_or(0);
4175
4176 let wal_checkpoint: String = if journal_mode == "wal" {
4177 let mut info = String::from("n/a");
4178 let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
4179 let busy: i64 = r.get(0)?;
4180 let checkpointed: i64 = r.get(1)?;
4181 let total: i64 = r.get(2)?;
4182 info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
4183 Ok(())
4184 });
4185 info
4186 } else {
4187 "n/a (not WAL mode)".to_string()
4188 };
4189
4190 let integrity: String = conn
4191 .pragma_query_value(None, "quick_check", |r| r.get(0))
4192 .unwrap_or_else(|_| "failed".to_string());
4193
4194 let db_size_bytes = page_count * page_size;
4195 let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
4196
4197 let mut tables = Vec::new();
4198 if let Ok(mut stmt) =
4199 conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4200 && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
4201 {
4202 for name in rows.flatten() {
4203 let count: i64 = conn
4204 .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
4205 .unwrap_or(0);
4206 tables.push(serde_json::json!({
4207 "name": name,
4208 "row_count": count,
4209 }));
4210 }
4211 }
4212
4213 Ok(serde_json::json!({
4214 "database": path_str,
4215 "journal_mode": journal_mode,
4216 "page_count": page_count,
4217 "page_size": page_size,
4218 "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
4219 "freelist_count": freelist_count,
4220 "wal_checkpoint": wal_checkpoint,
4221 "integrity_check": integrity,
4222 "tables": tables,
4223 }))
4224 })
4225 .await
4226 .map_err(|e| format!("db health task failed: {e}"))?
4227 }
4228
4229 fn check_bridge_version_once(&self) {
4230 if self.bridge_checked.swap(true, Ordering::Relaxed) {
4231 return;
4232 }
4233 let handler = self.clone();
4234 tokio::spawn(async move {
4235 match handler
4236 .eval_with_return_timeout(
4237 "window.__VICTAURI__?.version",
4238 None,
4239 std::time::Duration::from_secs(5),
4240 )
4241 .await
4242 {
4243 Ok(v) => {
4244 let v = v.trim_matches('"');
4245 if v == BRIDGE_VERSION {
4246 tracing::debug!("Bridge version verified: {v}");
4247 } else {
4248 tracing::warn!(
4249 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
4250 );
4251 }
4252 }
4253 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
4254 }
4255 });
4256 }
4257}
4258
4259const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
4260It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
4261(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
4262(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
4263\n\nBACKEND tools (direct Rust access, no webview needed): \
4264'app_info' (app config, directory paths, discovered databases, process info), \
4265'list_app_dir' (browse app data/config/log directories), \
4266'read_app_file' (read files from app directories), \
4267'query_db' (read-only SQLite queries with auto-discovery). \
4268\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
4269'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
4270capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
4271Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
4272capability/security auditing, database diagnostics, plugin state, child process enumeration, \
4273task tracking, and automatic Tauri event bus monitoring. \
4274'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
4275drops, and response corruption into Tauri commands at the Rust layer. \
4276'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
4277activity across IPC + DOM + console + network + window events into a coherent narrative. \
4278\n\nWEBVIEW tools: \
4279'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
4280'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
4281'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
4282\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
4283\n\nCOMPOUND tools with an 'action' parameter: \
4284'window' (get_state, list, manage, resize, move_to, set_title), \
4285'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
4286set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
4287get_events, events_between, get_replay, export, import, replay), \
4288'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
4289\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
4290async backend work to true completion), assert_semantic, resolve_command, \
4291app_state (app-defined backend state probes), \
4292get_memory_stats, get_plugin_info, get_diagnostics.";
4293
4294impl ServerHandler for VictauriMcpHandler {
4295 fn get_info(&self) -> ServerInfo {
4296 ServerInfo::new(
4297 ServerCapabilities::builder()
4298 .enable_tools()
4299 .enable_resources()
4300 .enable_resources_subscribe()
4301 .build(),
4302 )
4303 .with_instructions(SERVER_INSTRUCTIONS)
4304 }
4305
4306 async fn list_tools(
4307 &self,
4308 _request: Option<PaginatedRequestParams>,
4309 _context: RequestContext<RoleServer>,
4310 ) -> Result<ListToolsResult, ErrorData> {
4311 let all_tools = Self::tool_router().list_all();
4312 let filtered: Vec<Tool> = all_tools
4313 .into_iter()
4314 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
4315 .collect();
4316 Ok(ListToolsResult {
4317 tools: filtered,
4318 ..Default::default()
4319 })
4320 }
4321
4322 async fn call_tool(
4323 &self,
4324 request: CallToolRequestParams,
4325 context: RequestContext<RoleServer>,
4326 ) -> Result<CallToolResult, ErrorData> {
4327 let tool_name: String = request.name.as_ref().to_owned();
4328 if !self.state.privacy.is_tool_enabled(&tool_name) {
4329 tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
4330 return Ok(tool_disabled(&tool_name));
4331 }
4332 self.state
4333 .tool_invocations
4334 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4335 let start = std::time::Instant::now();
4336 tracing::debug!(tool = %tool_name, "tool invocation started");
4337 let ctx = ToolCallContext::new(self, request, context);
4338 let result = Self::tool_router().call(ctx).await;
4339 let elapsed = start.elapsed();
4340 tracing::debug!(
4341 tool = %tool_name,
4342 elapsed_ms = elapsed.as_millis() as u64,
4343 is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
4344 "tool invocation completed"
4345 );
4346
4347 if self.state.privacy.redaction_enabled {
4350 result.map(|mut r| {
4351 for item in &mut r.content {
4352 if let RawContent::Text(ref mut tc) = item.raw {
4353 tc.text = self.state.privacy.redact_output(&tc.text);
4354 }
4355 }
4356 r
4357 })
4358 } else {
4359 result
4360 }
4361 }
4362
4363 fn get_tool(&self, name: &str) -> Option<Tool> {
4364 if !self.state.privacy.is_tool_enabled(name) {
4365 return None;
4366 }
4367 Self::tool_router().get(name).cloned()
4368 }
4369
4370 async fn list_resources(
4371 &self,
4372 _request: Option<PaginatedRequestParams>,
4373 _context: RequestContext<RoleServer>,
4374 ) -> Result<ListResourcesResult, ErrorData> {
4375 Ok(ListResourcesResult {
4376 resources: vec![
4377 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
4378 .with_description(
4379 "Live IPC call log — all commands invoked between frontend and backend",
4380 )
4381 .with_mime_type("application/json")
4382 .no_annotation(),
4383 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
4384 .with_description(
4385 "Current state of all Tauri windows — position, size, visibility, focus",
4386 )
4387 .with_mime_type("application/json")
4388 .no_annotation(),
4389 RawResource::new(RESOURCE_URI_STATE, "state")
4390 .with_description(
4391 "Victauri plugin state — event count, registered commands, memory stats",
4392 )
4393 .with_mime_type("application/json")
4394 .no_annotation(),
4395 ],
4396 ..Default::default()
4397 })
4398 }
4399
4400 async fn read_resource(
4401 &self,
4402 request: ReadResourceRequestParams,
4403 _context: RequestContext<RoleServer>,
4404 ) -> Result<ReadResourceResult, ErrorData> {
4405 let uri = &request.uri;
4406 let json = match uri.as_str() {
4407 RESOURCE_URI_IPC_LOG => {
4408 if let Ok(json) = self
4409 .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
4410 .await
4411 {
4412 json
4413 } else {
4414 let calls = self.state.event_log.ipc_calls();
4415 serde_json::to_string_pretty(&calls)
4416 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4417 }
4418 }
4419 RESOURCE_URI_WINDOWS => {
4420 let states = self.bridge.get_window_states(None);
4421 serde_json::to_string_pretty(&states)
4422 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4423 }
4424 RESOURCE_URI_STATE => {
4425 let state_json = serde_json::json!({
4426 "events_captured": self.state.event_log.len(),
4427 "commands_registered": self.state.registry.count(),
4428 "memory": crate::memory::current_stats(),
4429 "port": self.state.port.load(Ordering::Relaxed),
4430 });
4431 serde_json::to_string_pretty(&state_json)
4432 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4433 }
4434 _ => {
4435 return Err(ErrorData::resource_not_found(
4436 format!("unknown resource: {uri}"),
4437 None,
4438 ));
4439 }
4440 };
4441
4442 let json = if self.state.privacy.redaction_enabled {
4443 self.state.privacy.redact_output(&json)
4444 } else {
4445 json
4446 };
4447
4448 Ok(ReadResourceResult::new(vec![ResourceContents::text(
4449 json, uri,
4450 )]))
4451 }
4452
4453 async fn subscribe(
4454 &self,
4455 request: SubscribeRequestParams,
4456 _context: RequestContext<RoleServer>,
4457 ) -> Result<(), ErrorData> {
4458 let uri = &request.uri;
4459 match uri.as_str() {
4460 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
4461 self.subscriptions.lock().await.insert(uri.clone());
4462 tracing::info!("Client subscribed to resource: {uri}");
4463 Ok(())
4464 }
4465 _ => Err(ErrorData::resource_not_found(
4466 format!("unknown resource: {uri}"),
4467 None,
4468 )),
4469 }
4470 }
4471
4472 async fn unsubscribe(
4473 &self,
4474 request: UnsubscribeRequestParams,
4475 _context: RequestContext<RoleServer>,
4476 ) -> Result<(), ErrorData> {
4477 self.subscriptions.lock().await.remove(&request.uri);
4478 tracing::info!("Client unsubscribed from resource: {}", request.uri);
4479 Ok(())
4480 }
4481}
4482
4483fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
4490 let mb = MAX_LOG_FIELD_BYTES;
4491 format!(
4492 r"return (function() {{
4493 var MB = {mb};
4494 function trimField(v) {{
4495 if (typeof v === 'string') {{
4496 return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
4497 }}
4498 if (v && typeof v === 'object') {{
4499 var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
4500 if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
4501 }}
4502 return v;
4503 }}
4504 function trimEntry(e) {{
4505 if (e == null || typeof e !== 'object') return e;
4506 var out = Array.isArray(e) ? [] : {{}};
4507 for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
4508 return out;
4509 }}
4510 var arr = {source_expr} || [];
4511 if (arr.length > {limit}) arr = arr.slice(-{limit});
4512 return arr.map(trimEntry);
4513 }})()"
4514 )
4515}
4516
4517fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
4528 if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
4529 if let Some(err) = envelope.get("__victauri_err") {
4530 return Err(format!(
4531 "JavaScript error: {}",
4532 err.as_str().unwrap_or("unknown error")
4533 ));
4534 }
4535 if envelope.get("__victauri_ok").is_some() {
4536 let js_type = envelope
4537 .get("__victauri_type")
4538 .and_then(|t| t.as_str())
4539 .unwrap_or("value");
4540 return match js_type {
4541 "undefined" => Ok("undefined".to_string()),
4542 "null" => Ok("null".to_string()),
4543 _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
4544 .unwrap_or_else(|_| "null".to_string())),
4545 };
4546 }
4547 }
4548 if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
4550 && let Some(idx) = after.rfind(r#","__victauri_type":"#)
4551 {
4552 return Ok(after[..idx].to_string());
4553 }
4554 if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
4555 let msg = after.trim_end_matches('}').trim_matches('"');
4556 return Err(format!("JavaScript error: {msg}"));
4557 }
4558 Ok(raw)
4559}
4560
4561const STMT_STARTS: &[&str] = &[
4563 "return ",
4564 "return;",
4565 "return\n",
4566 "return\t",
4567 "if ",
4568 "if(",
4569 "for ",
4570 "for(",
4571 "while ",
4572 "while(",
4573 "switch ",
4574 "switch(",
4575 "try ",
4576 "try{",
4577 "const ",
4578 "let ",
4579 "var ",
4580 "function ",
4581 "function(",
4582 "function*",
4583 "class ",
4584 "throw ",
4585 "do ",
4586 "do{",
4587 "{",
4588 "async function",
4589 "debugger",
4590];
4591
4592#[derive(PartialEq, Clone, Copy)]
4594enum ScanState {
4595 Code,
4596 SingleQuote,
4597 DoubleQuote,
4598 Template,
4599}
4600
4601fn should_prepend_return(code: &str) -> bool {
4612 use ScanState::{Code, DoubleQuote, SingleQuote, Template};
4613
4614 let code = code.trim();
4615 if code.is_empty() {
4616 return false;
4617 }
4618
4619 if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
4620 return false;
4621 }
4622
4623 let bytes = code.as_bytes();
4624 let mut i = 0;
4625 let mut depth: i32 = 0;
4626 let mut state = ScanState::Code;
4627
4628 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
4629 let is_return_token = |i: usize| -> bool {
4631 let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
4632 prev_ok
4633 && code[i..].starts_with("return")
4634 && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
4635 };
4636
4637 while i < bytes.len() {
4638 let c = bytes[i];
4639 match state {
4640 Code => match c {
4641 b'\'' => state = SingleQuote,
4642 b'"' => state = DoubleQuote,
4643 b'`' => state = Template,
4644 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
4645 while i < bytes.len() && bytes[i] != b'\n' {
4646 i += 1;
4647 }
4648 continue;
4649 }
4650 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4651 i += 2;
4652 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
4653 i += 1;
4654 }
4655 i += 2;
4656 continue;
4657 }
4658 b'(' | b'[' | b'{' => depth += 1,
4659 b')' | b']' | b'}' => depth -= 1,
4660 b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
4662 b'r' if depth <= 0 && is_return_token(i) => return false,
4664 _ => {}
4665 },
4666 SingleQuote => {
4667 if c == b'\\' {
4668 i += 1;
4669 } else if c == b'\'' {
4670 state = Code;
4671 }
4672 }
4673 DoubleQuote => {
4674 if c == b'\\' {
4675 i += 1;
4676 } else if c == b'"' {
4677 state = Code;
4678 }
4679 }
4680 Template => {
4681 if c == b'\\' {
4682 i += 1;
4683 } else if c == b'`' {
4684 state = Code;
4685 }
4686 }
4687 }
4688 i += 1;
4689 }
4690
4691 true
4692}
4693
4694#[cfg(test)]
4695mod prop_tests {
4696 use super::should_prepend_return;
4701 use proptest::prelude::*;
4702
4703 fn ident() -> impl Strategy<Value = String> {
4705 prop_oneof![
4706 Just("a".to_string()),
4707 Just("x".to_string()),
4708 Just("foo".to_string()),
4709 Just("window.x".to_string()),
4710 Just("document.title".to_string()),
4711 Just("obj.prop".to_string()),
4712 Just("arr[0]".to_string()),
4713 Just("localStorage".to_string()),
4714 ]
4715 }
4716
4717 fn bare_expr() -> impl Strategy<Value = String> {
4720 prop_oneof![
4721 ident(),
4722 (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
4723 (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
4724 ident().prop_map(|a| format!("{a}.length")),
4725 any::<u16>().prop_map(|n| n.to_string()),
4726 ]
4727 }
4728
4729 proptest! {
4730 #[test]
4733 fn never_panics_on_arbitrary_input(s in ".{0,256}") {
4734 let _ = should_prepend_return(&s);
4735 }
4736
4737 #[test]
4739 fn bare_expressions_are_prepended(e in bare_expr()) {
4740 prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
4741 }
4742
4743 #[test]
4746 fn semicolon_multistatement_with_return_never_prepended(
4747 setup in bare_expr(), ret in bare_expr()
4748 ) {
4749 let code = format!("{setup}; return {ret}");
4750 prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
4751 }
4752
4753 #[test]
4755 fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
4756 let code = format!("{pre}\nreturn {ret}");
4757 prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
4758 }
4759
4760 #[test]
4763 fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
4764 let code = format!("'do;not;split return {inner}'");
4766 prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
4767 }
4768 }
4769}
4770
4771#[cfg(test)]
4772mod tests {
4773 use super::*;
4774
4775 #[test]
4776 fn env_filter_drops_secrets_keeps_safe() {
4777 assert!(is_safe_env_key("HOME"));
4779 assert!(is_safe_env_key("LANG"));
4780 assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
4781 assert!(is_safe_env_key("VICTAURI_PORT"));
4782 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
4784 assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
4785 assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
4786 assert!(!is_safe_env_key("VICTAURI_API_KEY"));
4787 assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
4789 assert!(!is_safe_env_key("RANDOM_VAR"));
4790 assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
4793 assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
4796 assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
4797 assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
4798 assert!(!is_safe_env_key("VICTAURI_JWT"));
4799 assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
4800 }
4801
4802 #[test]
4803 fn prepend_return_bare_expressions() {
4804 assert!(should_prepend_return("document.title"));
4805 assert!(should_prepend_return("5 + 5"));
4806 assert!(should_prepend_return("\"justexpr\""));
4807 assert!(should_prepend_return("await fetch('/x')"));
4808 assert!(should_prepend_return(
4809 "document.querySelectorAll('a').length"
4810 ));
4811 assert!(should_prepend_return("x ? a : b"));
4812 assert!(should_prepend_return("document.title;"));
4814 assert!(should_prepend_return("'a;b;c'"));
4816 assert!(should_prepend_return("\"x;y\".length"));
4817 assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
4819 }
4820
4821 #[test]
4822 fn no_prepend_for_statement_blocks() {
4823 assert!(!should_prepend_return(
4825 "localStorage.setItem('k','v'); return localStorage.getItem('k')"
4826 ));
4827 assert!(!should_prepend_return(
4828 "window.scrollTo(0,50); return window.scrollY"
4829 ));
4830 assert!(!should_prepend_return("console.log('x'); return 123"));
4831 assert!(!should_prepend_return("window.__z=7; return 'ok'"));
4832 assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
4834 }
4835
4836 #[test]
4837 fn no_prepend_for_statement_keywords() {
4838 assert!(!should_prepend_return("return 42"));
4839 assert!(!should_prepend_return("const x = 1; return x"));
4840 assert!(!should_prepend_return("let y = 2"));
4841 assert!(!should_prepend_return("var z = 3"));
4842 assert!(!should_prepend_return("if (x) { return 1 }"));
4843 assert!(!should_prepend_return("for (const x of y) doThing(x)"));
4844 assert!(!should_prepend_return("throw new Error('x')"));
4845 assert!(!should_prepend_return("function f(){}"));
4846 assert!(!should_prepend_return("{ a: 1 }")); }
4848
4849 #[test]
4850 fn empty_code_no_prepend() {
4851 assert!(!should_prepend_return(""));
4852 assert!(!should_prepend_return(" "));
4853 }
4854
4855 #[test]
4856 fn envelope_unwrap_value() {
4857 assert_eq!(
4858 unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
4859 Ok("\"4DA\"".to_string())
4860 );
4861 assert_eq!(
4862 unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
4863 Ok("42".to_string())
4864 );
4865 }
4866
4867 #[test]
4868 fn envelope_unwrap_undefined_null() {
4869 assert_eq!(
4870 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
4871 Ok("undefined".to_string())
4872 );
4873 assert_eq!(
4874 unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
4875 Ok("null".to_string())
4876 );
4877 }
4878
4879 #[test]
4880 fn envelope_unwrap_error() {
4881 let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
4882 assert!(r.unwrap_err().contains("boom"));
4883 }
4884
4885 #[test]
4886 fn envelope_unwrap_deeply_nested_does_not_leak() {
4887 let mut value = String::from("0");
4891 for _ in 0..300 {
4892 value = format!("{{\"n\":{value}}}");
4893 }
4894 let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
4895 let out = unwrap_eval_envelope(raw).unwrap();
4896 assert!(
4897 out.starts_with(r#"{"n":"#),
4898 "deep value should be unwrapped, got: {}",
4899 &out[..out.len().min(40)]
4900 );
4901 assert!(
4902 !out.contains("__victauri_ok"),
4903 "envelope must not leak into the result"
4904 );
4905 }
4906
4907 #[test]
4908 fn js_string_simple() {
4909 assert_eq!(js_string("hello"), "\"hello\"");
4910 }
4911
4912 #[test]
4913 fn js_string_single_quotes() {
4914 let result = js_string("it's a test");
4915 assert!(result.contains("it's a test"));
4916 }
4917
4918 #[test]
4919 fn js_string_double_quotes() {
4920 let result = js_string(r#"say "hello""#);
4921 assert!(result.contains(r#"\""#));
4922 }
4923
4924 #[test]
4925 fn js_string_backslashes() {
4926 let result = js_string(r"path\to\file");
4927 assert!(result.contains(r"\\"));
4928 }
4929
4930 #[test]
4931 fn js_string_newlines_and_tabs() {
4932 let result = js_string("line1\nline2\ttab");
4933 assert!(result.contains(r"\n"));
4934 assert!(result.contains(r"\t"));
4935 assert!(!result.contains('\n'));
4936 }
4937
4938 #[test]
4939 fn js_string_null_bytes() {
4940 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
4941 let result = js_string(&input);
4942 assert!(result.contains("\\u0000"));
4944 assert!(!result.contains('\0'));
4945 }
4946
4947 #[test]
4948 fn js_string_template_literal_injection() {
4949 let result = js_string("`${alert(1)}`");
4950 assert!(result.starts_with('"'));
4953 assert!(result.ends_with('"'));
4954 }
4955
4956 #[test]
4957 fn js_string_unicode_separators() {
4958 let result = js_string("a\u{2028}b\u{2029}c");
4963 let decoded: String = serde_json::from_str(&result).unwrap();
4965 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
4966 }
4967
4968 #[test]
4969 fn js_string_empty() {
4970 assert_eq!(js_string(""), "\"\"");
4971 }
4972
4973 #[test]
4974 fn js_string_html_script_close() {
4975 let result = js_string("</script><img onerror=alert(1)>");
4977 assert!(result.starts_with('"'));
4978 let decoded: String = serde_json::from_str(&result).unwrap();
4980 assert_eq!(decoded, "</script><img onerror=alert(1)>");
4981 }
4982
4983 #[test]
4984 fn js_string_very_long() {
4985 let long = "a".repeat(100_000);
4986 let result = js_string(&long);
4987 assert!(result.len() >= 100_002); }
4989
4990 #[test]
4993 fn url_allows_http() {
4994 assert!(validate_url("http://example.com", false).is_ok());
4995 }
4996
4997 #[test]
4998 fn url_allows_https() {
4999 assert!(validate_url("https://example.com/path?q=1", false).is_ok());
5000 }
5001
5002 #[test]
5003 fn url_allows_http_localhost() {
5004 assert!(validate_url("http://localhost:3000", false).is_ok());
5005 }
5006
5007 #[test]
5008 fn url_blocks_file_by_default() {
5009 let err = validate_url("file:///etc/passwd", false).unwrap_err();
5010 assert!(err.contains("file"), "error should mention the file scheme");
5011 }
5012
5013 #[test]
5014 fn url_allows_file_when_opted_in() {
5015 assert!(validate_url("file:///tmp/test.html", true).is_ok());
5016 }
5017
5018 #[test]
5019 fn url_blocks_javascript() {
5020 assert!(validate_url("javascript:alert(1)", false).is_err());
5021 }
5022
5023 #[test]
5024 fn url_blocks_javascript_case_insensitive() {
5025 assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
5026 }
5027
5028 #[test]
5029 fn url_blocks_data_scheme() {
5030 assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
5031 }
5032
5033 #[test]
5034 fn url_blocks_vbscript() {
5035 assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
5036 }
5037
5038 #[test]
5039 fn url_rejects_invalid() {
5040 assert!(validate_url("not a url at all", false).is_err());
5041 }
5042
5043 #[test]
5044 fn url_strips_control_chars() {
5045 let input = format!("http://example{}com", '\0');
5047 assert!(validate_url(&input, false).is_ok());
5048 }
5049
5050 #[test]
5053 fn css_color_valid_hex() {
5054 assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
5055 assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
5056 assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
5057 }
5058
5059 #[test]
5060 fn css_color_valid_rgb() {
5061 assert_eq!(
5062 sanitize_css_color("rgb(255, 0, 0)").unwrap(),
5063 "rgb(255, 0, 0)"
5064 );
5065 assert_eq!(
5066 sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
5067 "rgba(0, 0, 0, 0.5)"
5068 );
5069 }
5070
5071 #[test]
5072 fn css_color_valid_named() {
5073 assert_eq!(sanitize_css_color("red").unwrap(), "red");
5074 assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
5075 }
5076
5077 #[test]
5078 fn css_color_valid_hsl() {
5079 assert_eq!(
5080 sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
5081 "hsl(120, 50%, 50%)"
5082 );
5083 }
5084
5085 #[test]
5086 fn css_color_rejects_too_long() {
5087 let long = "a".repeat(101);
5088 assert!(sanitize_css_color(&long).is_err());
5089 }
5090
5091 #[test]
5092 fn css_color_rejects_backslash_escapes() {
5093 assert!(sanitize_css_color(r"red\00").is_err());
5094 assert!(sanitize_css_color(r"\72\65\64").is_err());
5095 }
5096
5097 #[test]
5098 fn css_color_rejects_url_injection() {
5099 assert!(sanitize_css_color("url(http://evil.com)").is_err());
5100 assert!(sanitize_css_color("URL(http://evil.com)").is_err());
5101 }
5102
5103 #[test]
5104 fn css_color_rejects_expression_injection() {
5105 assert!(sanitize_css_color("expression(alert(1))").is_err());
5106 assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
5107 }
5108
5109 #[test]
5110 fn css_color_rejects_import() {
5111 assert!(sanitize_css_color("@import url(evil.css)").is_err());
5112 }
5113
5114 #[test]
5115 fn css_color_rejects_semicolons_and_braces() {
5116 assert!(sanitize_css_color("red; background: url(evil)").is_err());
5117 assert!(sanitize_css_color("red} body { color: blue").is_err());
5118 }
5119
5120 #[test]
5121 fn css_color_rejects_special_chars() {
5122 assert!(sanitize_css_color("red<script>").is_err());
5123 assert!(sanitize_css_color("red\"onload=alert").is_err());
5124 assert!(sanitize_css_color("red'onclick=alert").is_err());
5125 }
5126
5127 #[test]
5128 fn css_color_trims_whitespace() {
5129 assert_eq!(sanitize_css_color(" red ").unwrap(), "red");
5130 }
5131
5132 #[test]
5133 fn css_color_empty_string() {
5134 assert_eq!(sanitize_css_color("").unwrap(), "");
5135 }
5136}