1use std::collections::HashSet;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use rmcp::handler::server::tool::ToolCallContext;
6use rmcp::handler::server::wrapper::Parameters;
7use rmcp::model::{
8 AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
9 ListToolsResult, PaginatedRequestParams, RawResource, ReadResourceRequestParams,
10 ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
11 Tool, UnsubscribeRequestParams,
12};
13use rmcp::service::RequestContext;
14use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
15use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
16use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
17use schemars::JsonSchema;
18use serde::Deserialize;
19use tauri::Runtime;
20use tokio::sync::Mutex;
21
22use crate::VictauriState;
23use crate::bridge::WebviewBridge;
24
25fn js_string(s: &str) -> String {
29 serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
30}
31
32#[derive(Debug, Deserialize, JsonSchema)]
35pub struct EvalJsParams {
36 pub code: String,
38 pub webview_label: Option<String>,
40}
41
42#[derive(Debug, Deserialize, JsonSchema)]
43pub struct ClickParams {
44 pub ref_id: String,
46 pub webview_label: Option<String>,
48}
49
50#[derive(Debug, Deserialize, JsonSchema)]
51pub struct FillParams {
52 pub ref_id: String,
54 pub value: String,
56 pub webview_label: Option<String>,
58}
59
60#[derive(Debug, Deserialize, JsonSchema)]
61pub struct TypeTextParams {
62 pub ref_id: String,
64 pub text: String,
66 pub webview_label: Option<String>,
68}
69
70#[derive(Debug, Deserialize, JsonSchema)]
71pub struct SnapshotParams {
72 pub webview_label: Option<String>,
74}
75
76#[derive(Debug, Deserialize, JsonSchema)]
77pub struct WindowStateParams {
78 pub label: Option<String>,
80}
81
82#[derive(Debug, Deserialize, JsonSchema)]
83pub struct IpcLogParams {
84 pub limit: Option<usize>,
86 pub webview_label: Option<String>,
88}
89
90#[derive(Debug, Deserialize, JsonSchema)]
91pub struct RegistryParams {
92 pub query: Option<String>,
94}
95
96#[derive(Debug, Deserialize, JsonSchema)]
97pub struct VerifyStateParams {
98 pub frontend_expr: String,
100 pub backend_state: serde_json::Value,
102 pub webview_label: Option<String>,
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct GhostCommandParams {
108 pub webview_label: Option<String>,
110}
111
112#[derive(Debug, Deserialize, JsonSchema)]
113pub struct IpcIntegrityParams {
114 pub stale_threshold_ms: Option<i64>,
116 pub webview_label: Option<String>,
118}
119
120#[derive(Debug, Deserialize, JsonSchema)]
121pub struct EventStreamParams {
122 pub since: Option<f64>,
124 pub webview_label: Option<String>,
126}
127
128#[derive(Debug, Deserialize, JsonSchema)]
129pub struct StartRecordingParams {
130 pub session_id: Option<String>,
132}
133
134#[derive(Debug, Deserialize, JsonSchema)]
135pub struct CheckpointParams {
136 pub id: String,
138 pub label: Option<String>,
140 pub state: serde_json::Value,
142}
143
144#[derive(Debug, Deserialize, JsonSchema)]
145pub struct ReplayParams {
146 pub since_index: Option<usize>,
148}
149
150#[derive(Debug, Deserialize, JsonSchema)]
151pub struct EventsBetweenCheckpointsParams {
152 pub from_checkpoint: String,
154 pub to_checkpoint: String,
156}
157
158#[derive(Debug, Deserialize, JsonSchema)]
159pub struct ResolveCommandParams {
160 pub query: String,
162 pub limit: Option<usize>,
164}
165
166#[derive(Debug, Deserialize, JsonSchema)]
167pub struct SemanticAssertParams {
168 pub expression: String,
170 pub label: String,
172 pub condition: String,
174 pub expected: serde_json::Value,
176 pub webview_label: Option<String>,
178}
179
180#[derive(Debug, Deserialize, JsonSchema)]
181pub struct InvokeCommandParams {
182 pub command: String,
184 pub args: Option<serde_json::Value>,
186 pub webview_label: Option<String>,
188}
189
190#[derive(Debug, Deserialize, JsonSchema)]
191pub struct ScreenshotParams {
192 pub window_label: Option<String>,
194}
195
196#[derive(Debug, Deserialize, JsonSchema)]
197pub struct PressKeyParams {
198 pub key: String,
200 pub webview_label: Option<String>,
202}
203
204#[derive(Debug, Deserialize, JsonSchema)]
205pub struct GetConsoleLogsParams {
206 pub since: Option<f64>,
208 pub webview_label: Option<String>,
210}
211
212#[derive(Debug, Deserialize, JsonSchema)]
213pub struct DoubleClickParams {
214 pub ref_id: String,
216 pub webview_label: Option<String>,
218}
219
220#[derive(Debug, Deserialize, JsonSchema)]
221pub struct HoverParams {
222 pub ref_id: String,
224 pub webview_label: Option<String>,
226}
227
228#[derive(Debug, Deserialize, JsonSchema)]
229pub struct SelectOptionParams {
230 pub ref_id: String,
232 pub values: Vec<String>,
234 pub webview_label: Option<String>,
236}
237
238#[derive(Debug, Deserialize, JsonSchema)]
239pub struct ScrollToParams {
240 pub ref_id: Option<String>,
242 pub x: Option<f64>,
244 pub y: Option<f64>,
246 pub webview_label: Option<String>,
248}
249
250#[derive(Debug, Deserialize, JsonSchema)]
251pub struct FocusElementParams {
252 pub ref_id: String,
254 pub webview_label: Option<String>,
256}
257
258#[derive(Debug, Deserialize, JsonSchema)]
259pub struct NetworkLogParams {
260 pub filter: Option<String>,
262 pub limit: Option<usize>,
264 pub webview_label: Option<String>,
266}
267
268#[derive(Debug, Deserialize, JsonSchema)]
269pub struct GetStorageParams {
270 pub storage_type: String,
272 pub key: Option<String>,
274 pub webview_label: Option<String>,
276}
277
278#[derive(Debug, Deserialize, JsonSchema)]
279pub struct SetStorageParams {
280 pub storage_type: String,
282 pub key: String,
284 pub value: serde_json::Value,
286 pub webview_label: Option<String>,
288}
289
290#[derive(Debug, Deserialize, JsonSchema)]
291pub struct DeleteStorageParams {
292 pub storage_type: String,
294 pub key: String,
296 pub webview_label: Option<String>,
298}
299
300#[derive(Debug, Deserialize, JsonSchema)]
301pub struct GetCookiesParams {
302 pub webview_label: Option<String>,
304}
305
306#[derive(Debug, Deserialize, JsonSchema)]
307pub struct NavigationLogParams {
308 pub webview_label: Option<String>,
310}
311
312#[derive(Debug, Deserialize, JsonSchema)]
313pub struct NavigateParams {
314 pub url: String,
316 pub webview_label: Option<String>,
318}
319
320#[derive(Debug, Deserialize, JsonSchema)]
321pub struct DialogLogParams {
322 pub webview_label: Option<String>,
324}
325
326#[derive(Debug, Deserialize, JsonSchema)]
327pub struct SetDialogResponseParams {
328 pub dialog_type: String,
330 pub action: String,
332 pub text: Option<String>,
334 pub webview_label: Option<String>,
336}
337
338#[derive(Debug, Deserialize, JsonSchema)]
339pub struct WaitForParams {
340 pub condition: String,
342 pub value: Option<String>,
344 pub timeout_ms: Option<u64>,
346 pub poll_ms: Option<u64>,
348 pub webview_label: Option<String>,
350}
351
352#[derive(Debug, Deserialize, JsonSchema)]
353pub struct ManageWindowParams {
354 pub action: String,
356 pub label: Option<String>,
358}
359
360#[derive(Debug, Deserialize, JsonSchema)]
361pub struct ResizeWindowParams {
362 pub width: u32,
364 pub height: u32,
366 pub label: Option<String>,
368}
369
370#[derive(Debug, Deserialize, JsonSchema)]
371pub struct MoveWindowParams {
372 pub x: i32,
374 pub y: i32,
376 pub label: Option<String>,
378}
379
380#[derive(Debug, Deserialize, JsonSchema)]
381pub struct SetWindowTitleParams {
382 pub title: String,
384 pub label: Option<String>,
386}
387
388#[derive(Debug, Deserialize, JsonSchema)]
389pub struct GetStylesParams {
390 pub ref_id: String,
392 pub properties: Option<Vec<String>>,
394 pub webview_label: Option<String>,
396}
397
398#[derive(Debug, Deserialize, JsonSchema)]
399pub struct GetBoundingBoxesParams {
400 pub ref_ids: Vec<String>,
402 pub webview_label: Option<String>,
404}
405
406#[derive(Debug, Deserialize, JsonSchema)]
407pub struct HighlightElementParams {
408 pub ref_id: String,
410 pub color: Option<String>,
412 pub label: Option<String>,
414 pub webview_label: Option<String>,
416}
417
418#[derive(Debug, Deserialize, JsonSchema)]
419pub struct ClearHighlightsParams {
420 pub webview_label: Option<String>,
422}
423
424#[derive(Debug, Deserialize, JsonSchema)]
425pub struct InjectCssParams {
426 pub css: String,
428 pub webview_label: Option<String>,
430}
431
432#[derive(Debug, Deserialize, JsonSchema)]
433pub struct RemoveInjectedCssParams {
434 pub webview_label: Option<String>,
436}
437
438#[derive(Debug, Deserialize, JsonSchema)]
439pub struct AuditAccessibilityParams {
440 pub webview_label: Option<String>,
442}
443
444#[derive(Debug, Deserialize, JsonSchema)]
445pub struct GetPerformanceMetricsParams {
446 pub webview_label: Option<String>,
448}
449
450#[derive(Debug, Deserialize, JsonSchema)]
451pub struct ImportSessionParams {
452 pub session_json: String,
454}
455
456#[derive(Debug, Deserialize, JsonSchema)]
457pub struct SlowIpcParams {
458 pub threshold_ms: u64,
460 pub limit: Option<usize>,
462}
463
464const MAX_PENDING_EVALS: usize = 100;
469
470const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
471const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
472const RESOURCE_URI_STATE: &str = "victauri://state";
473
474const BRIDGE_VERSION: &str = "0.2.0";
475
476#[derive(Clone)]
477pub struct VictauriMcpHandler {
478 state: Arc<VictauriState>,
479 bridge: Arc<dyn WebviewBridge>,
480 subscriptions: Arc<Mutex<HashSet<String>>>,
481 bridge_checked: Arc<AtomicBool>,
482}
483
484#[tool_router]
485impl VictauriMcpHandler {
486 #[tool(
487 description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically."
488 )]
489 async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
490 if !self.state.privacy.is_tool_enabled("eval_js") {
491 return tool_disabled("eval_js");
492 }
493 match self
494 .eval_with_return(¶ms.code, params.webview_label.as_deref())
495 .await
496 {
497 Ok(result) => self.redact_result(result),
498 Err(e) => tool_error(e),
499 }
500 }
501
502 #[tool(
503 description = "Get the current DOM snapshot from the webview as a JSON accessibility tree with ref handles for interaction."
504 )]
505 async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
506 let code = "return window.__VICTAURI__?.snapshot()";
507 match self
508 .eval_with_return(code, params.webview_label.as_deref())
509 .await
510 {
511 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
512 Err(e) => tool_error(e),
513 }
514 }
515
516 #[tool(description = "Click an element by its ref handle ID from a DOM snapshot.")]
517 async fn click(&self, Parameters(params): Parameters<ClickParams>) -> CallToolResult {
518 let code = format!(
519 "return window.__VICTAURI__?.click({})",
520 js_string(¶ms.ref_id)
521 );
522 match self
523 .eval_with_return(&code, params.webview_label.as_deref())
524 .await
525 {
526 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
527 Err(e) => tool_error(e),
528 }
529 }
530
531 #[tool(
532 description = "Set the value of an input element by ref handle ID. Dispatches input and change events."
533 )]
534 async fn fill(&self, Parameters(params): Parameters<FillParams>) -> CallToolResult {
535 if !self.state.privacy.is_tool_enabled("fill") {
536 return tool_disabled("fill");
537 }
538 let code = format!(
539 "return window.__VICTAURI__?.fill({}, {})",
540 js_string(¶ms.ref_id),
541 js_string(¶ms.value)
542 );
543 match self
544 .eval_with_return(&code, params.webview_label.as_deref())
545 .await
546 {
547 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
548 Err(e) => tool_error(e),
549 }
550 }
551
552 #[tool(
553 description = "Type text character-by-character into an element, simulating real keyboard events."
554 )]
555 async fn type_text(&self, Parameters(params): Parameters<TypeTextParams>) -> CallToolResult {
556 if !self.state.privacy.is_tool_enabled("type_text") {
557 return tool_disabled("type_text");
558 }
559 let code = format!(
560 "return window.__VICTAURI__?.type({}, {})",
561 js_string(¶ms.ref_id),
562 js_string(¶ms.text)
563 );
564 match self
565 .eval_with_return(&code, params.webview_label.as_deref())
566 .await
567 {
568 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
569 Err(e) => tool_error(e),
570 }
571 }
572
573 #[tool(
574 description = "Get state of all Tauri windows: position, size, visibility, focus, and URL."
575 )]
576 async fn get_window_state(
577 &self,
578 Parameters(params): Parameters<WindowStateParams>,
579 ) -> CallToolResult {
580 let states = self.bridge.get_window_states(params.label.as_deref());
581 match serde_json::to_string_pretty(&states) {
582 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
583 Err(e) => tool_error(e.to_string()),
584 }
585 }
586
587 #[tool(description = "List all Tauri window labels.")]
588 async fn list_windows(&self) -> CallToolResult {
589 let labels = self.bridge.list_window_labels();
590 match serde_json::to_string_pretty(&labels) {
591 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
592 Err(e) => tool_error(e.to_string()),
593 }
594 }
595
596 #[tool(
597 description = "Get recent IPC calls intercepted by the JS bridge. Returns command names, arguments, results, durations, and status (ok/error/pending)."
598 )]
599 async fn get_ipc_log(&self, Parameters(params): Parameters<IpcLogParams>) -> CallToolResult {
600 let limit_arg = params.limit.map(|l| format!("{l}")).unwrap_or_default();
601 let code = if limit_arg.is_empty() {
602 "return window.__VICTAURI__?.getIpcLog()".to_string()
603 } else {
604 format!("return window.__VICTAURI__?.getIpcLog({limit_arg})")
605 };
606 match self
607 .eval_with_return(&code, params.webview_label.as_deref())
608 .await
609 {
610 Ok(result) => self.redact_result(result),
611 Err(e) => tool_error(e),
612 }
613 }
614
615 #[tool(
616 description = "List or search all registered Tauri commands with their argument schemas."
617 )]
618 async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
619 let commands = match params.query {
620 Some(q) => self.state.registry.search(&q),
621 None => self.state.registry.list(),
622 };
623 match serde_json::to_string_pretty(&commands) {
624 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
625 Err(e) => tool_error(e.to_string()),
626 }
627 }
628
629 #[tool(
630 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."
631 )]
632 async fn get_memory_stats(&self) -> CallToolResult {
633 let stats = crate::memory::current_stats();
634 match serde_json::to_string_pretty(&stats) {
635 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
636 Err(e) => tool_error(e.to_string()),
637 }
638 }
639
640 #[tool(
641 description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches."
642 )]
643 async fn verify_state(
644 &self,
645 Parameters(params): Parameters<VerifyStateParams>,
646 ) -> CallToolResult {
647 let code = format!("return ({})", params.frontend_expr);
648 let frontend_json = match self
649 .eval_with_return(&code, params.webview_label.as_deref())
650 .await
651 {
652 Ok(result) => result,
653 Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
654 };
655
656 let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
657 Ok(v) => v,
658 Err(e) => {
659 return tool_error(format!(
660 "frontend expression did not return valid JSON: {e}"
661 ));
662 }
663 };
664
665 let result = victauri_core::verify_state(frontend_state, params.backend_state);
666 match serde_json::to_string_pretty(&result) {
667 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
668 Err(e) => tool_error(e.to_string()),
669 }
670 }
671
672 #[tool(
673 description = "Detect ghost commands — commands invoked from the frontend that have no backend handler, or registered backend commands never called. Reads from the JS-side IPC interception log."
674 )]
675 async fn detect_ghost_commands(
676 &self,
677 Parameters(params): Parameters<GhostCommandParams>,
678 ) -> CallToolResult {
679 let code = "return window.__VICTAURI__?.getIpcLog()";
680 let ipc_json = match self
681 .eval_with_return(code, params.webview_label.as_deref())
682 .await
683 {
684 Ok(r) => r,
685 Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
686 };
687
688 let ipc_calls: Vec<serde_json::Value> = serde_json::from_str(&ipc_json).unwrap_or_default();
689 let frontend_commands: Vec<String> = ipc_calls
690 .iter()
691 .filter_map(|c| c.get("command").and_then(|v| v.as_str()).map(String::from))
692 .collect::<std::collections::HashSet<_>>()
693 .into_iter()
694 .collect();
695
696 let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
697 match serde_json::to_string_pretty(&report) {
698 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
699 Err(e) => tool_error(e.to_string()),
700 }
701 }
702
703 #[tool(
704 description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls."
705 )]
706 async fn check_ipc_integrity(
707 &self,
708 Parameters(params): Parameters<IpcIntegrityParams>,
709 ) -> CallToolResult {
710 let threshold = params.stale_threshold_ms.unwrap_or(5000);
711 let code = format!(
712 r#"return (function() {{
713 var log = window.__VICTAURI__?.getIpcLog() || [];
714 var now = Date.now();
715 var threshold = {threshold};
716 var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
717 var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
718 var errored = log.filter(function(c) {{ return c.status === 'error'; }});
719 return {{
720 healthy: stale.length === 0 && errored.length === 0,
721 total_calls: log.length,
722 pending_count: pending.length,
723 stale_count: stale.length,
724 error_count: errored.length,
725 stale_calls: stale.slice(0, 20),
726 errored_calls: errored.slice(0, 20)
727 }};
728 }})()"#
729 );
730 match self
731 .eval_with_return(&code, params.webview_label.as_deref())
732 .await
733 {
734 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
735 Err(e) => tool_error(e),
736 }
737 }
738
739 #[tool(
740 description = "Get a combined event stream from the webview: console logs, DOM mutations, sorted by timestamp. Use the 'since' parameter to poll only new events."
741 )]
742 async fn get_event_stream(
743 &self,
744 Parameters(params): Parameters<EventStreamParams>,
745 ) -> CallToolResult {
746 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
747 let code = if since_arg.is_empty() {
748 "return window.__VICTAURI__?.getEventStream()".to_string()
749 } else {
750 format!("return window.__VICTAURI__?.getEventStream({since_arg})")
751 };
752 match self
753 .eval_with_return(&code, params.webview_label.as_deref())
754 .await
755 {
756 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
757 Err(e) => tool_error(e),
758 }
759 }
760
761 #[tool(
762 description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples."
763 )]
764 async fn resolve_command(
765 &self,
766 Parameters(params): Parameters<ResolveCommandParams>,
767 ) -> CallToolResult {
768 let limit = params.limit.unwrap_or(5);
769 let mut results = self.state.registry.resolve(¶ms.query);
770 results.truncate(limit);
771 match serde_json::to_string_pretty(&results) {
772 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
773 Err(e) => tool_error(e.to_string()),
774 }
775 }
776
777 #[tool(
778 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."
779 )]
780 async fn assert_semantic(
781 &self,
782 Parameters(params): Parameters<SemanticAssertParams>,
783 ) -> CallToolResult {
784 let code = format!("return ({})", params.expression);
785 let actual_json = match self
786 .eval_with_return(&code, params.webview_label.as_deref())
787 .await
788 {
789 Ok(result) => result,
790 Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
791 };
792
793 let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
794 Ok(v) => v,
795 Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
796 };
797
798 let assertion = victauri_core::SemanticAssertion {
799 label: params.label,
800 condition: params.condition,
801 expected: params.expected,
802 };
803
804 let result = victauri_core::evaluate_assertion(actual, &assertion);
805 match serde_json::to_string_pretty(&result) {
806 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
807 Err(e) => tool_error(e.to_string()),
808 }
809 }
810
811 #[tool(
812 description = "Start recording IPC events and state changes. Returns false if a recording is already active."
813 )]
814 async fn start_recording(
815 &self,
816 Parameters(params): Parameters<StartRecordingParams>,
817 ) -> CallToolResult {
818 let session_id = params
819 .session_id
820 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
821 let started = self.state.recorder.start(session_id.clone());
822 let result = serde_json::json!({
823 "started": started,
824 "session_id": session_id,
825 });
826 CallToolResult::success(vec![Content::text(result.to_string())])
827 }
828
829 #[tool(
830 description = "Stop the current recording and return the full recorded session with all events and checkpoints."
831 )]
832 async fn stop_recording(&self) -> CallToolResult {
833 match self.state.recorder.stop() {
834 Some(session) => match serde_json::to_string_pretty(&session) {
835 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
836 Err(e) => tool_error(e.to_string()),
837 },
838 None => tool_error("no recording is active"),
839 }
840 }
841
842 #[tool(
843 description = "Create a state checkpoint during recording. Associates the current event index with a state snapshot for later comparison."
844 )]
845 async fn checkpoint(&self, Parameters(params): Parameters<CheckpointParams>) -> CallToolResult {
846 let created = self
847 .state
848 .recorder
849 .checkpoint(params.id.clone(), params.label, params.state);
850 if created {
851 let result = serde_json::json!({
852 "created": true,
853 "checkpoint_id": params.id,
854 "event_index": self.state.recorder.event_count(),
855 });
856 CallToolResult::success(vec![Content::text(result.to_string())])
857 } else {
858 tool_error("no recording is active — start one first")
859 }
860 }
861
862 #[tool(description = "Get all checkpoints from the current recording session.")]
863 async fn list_checkpoints(&self) -> CallToolResult {
864 let checkpoints = self.state.recorder.get_checkpoints();
865 match serde_json::to_string_pretty(&checkpoints) {
866 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
867 Err(e) => tool_error(e.to_string()),
868 }
869 }
870
871 #[tool(
872 description = "Get the IPC replay sequence: all IPC calls recorded in order, suitable for replaying the session."
873 )]
874 async fn get_replay_sequence(&self) -> CallToolResult {
875 let calls = self.state.recorder.ipc_replay_sequence();
876 match serde_json::to_string_pretty(&calls) {
877 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
878 Err(e) => tool_error(e.to_string()),
879 }
880 }
881
882 #[tool(
883 description = "Get recorded events since a specific event index. Useful for incremental replay."
884 )]
885 async fn get_recorded_events(
886 &self,
887 Parameters(params): Parameters<ReplayParams>,
888 ) -> CallToolResult {
889 let events = self
890 .state
891 .recorder
892 .events_since(params.since_index.unwrap_or(0));
893 match serde_json::to_string_pretty(&events) {
894 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
895 Err(e) => tool_error(e.to_string()),
896 }
897 }
898
899 #[tool(description = "Get all events that occurred between two checkpoints.")]
900 async fn events_between_checkpoints(
901 &self,
902 Parameters(params): Parameters<EventsBetweenCheckpointsParams>,
903 ) -> CallToolResult {
904 match self
905 .state
906 .recorder
907 .events_between_checkpoints(¶ms.from_checkpoint, ¶ms.to_checkpoint)
908 {
909 Some(events) => match serde_json::to_string_pretty(&events) {
910 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
911 Err(e) => tool_error(e.to_string()),
912 },
913 None => tool_error("one or both checkpoint IDs not found"),
914 }
915 }
916
917 #[tool(
918 description = "Export the current recording session as a JSON string. The session can be saved externally and later imported with import_session. Does NOT stop the recording."
919 )]
920 async fn export_session(&self) -> CallToolResult {
921 let rec = self.state.recorder.clone();
922 if !rec.is_recording() {
923 return tool_error("no recording is active — start one first");
924 }
925 let session = rec.stop();
926 match session {
927 Some(s) => {
928 let json = serde_json::to_string_pretty(&s)
929 .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
930 CallToolResult::success(vec![Content::text(json)])
931 }
932 None => tool_error("failed to export session"),
933 }
934 }
935
936 #[tool(
937 description = "Import a previously exported recording session from JSON. Useful for replaying sessions across restarts. The imported session can be queried with replay and checkpoint tools."
938 )]
939 async fn import_session(
940 &self,
941 Parameters(params): Parameters<ImportSessionParams>,
942 ) -> CallToolResult {
943 let session: victauri_core::RecordedSession =
944 match serde_json::from_str(¶ms.session_json) {
945 Ok(s) => s,
946 Err(e) => return tool_error(format!("invalid session JSON: {e}")),
947 };
948
949 let result = serde_json::json!({
950 "imported": true,
951 "session_id": session.id,
952 "event_count": session.events.len(),
953 "checkpoint_count": session.checkpoints.len(),
954 "started_at": session.started_at.to_rfc3339(),
955 });
956 CallToolResult::success(vec![Content::text(result.to_string())])
957 }
958
959 #[tool(
960 description = "Find slow IPC calls that exceed a time threshold. Returns calls sorted by duration (slowest first). Useful for identifying performance bottlenecks."
961 )]
962 async fn slow_ipc_calls(
963 &self,
964 Parameters(params): Parameters<SlowIpcParams>,
965 ) -> CallToolResult {
966 let limit = params.limit.unwrap_or(20);
967 let mut calls: Vec<_> = self
968 .state
969 .event_log
970 .ipc_calls()
971 .into_iter()
972 .filter(|c| c.duration_ms.unwrap_or(0) > params.threshold_ms)
973 .collect();
974 calls.sort_by_key(|c| std::cmp::Reverse(c.duration_ms));
975 calls.truncate(limit);
976
977 let result = serde_json::json!({
978 "threshold_ms": params.threshold_ms,
979 "count": calls.len(),
980 "calls": calls,
981 });
982 match serde_json::to_string_pretty(&result) {
983 Ok(json) => self.redact_result(json),
984 Err(e) => tool_error(e.to_string()),
985 }
986 }
987
988 #[tool(
989 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."
990 )]
991 async fn invoke_command(
992 &self,
993 Parameters(params): Parameters<InvokeCommandParams>,
994 ) -> CallToolResult {
995 if !self.state.privacy.is_command_allowed(¶ms.command) {
996 return tool_error(format!(
997 "command '{}' is blocked by privacy configuration",
998 params.command
999 ));
1000 }
1001 let args_json = params.args.unwrap_or(serde_json::json!({}));
1002 let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
1003 let code = format!(
1004 "return window.__TAURI__.core.invoke({}, {args_str})",
1005 js_string(¶ms.command)
1006 );
1007 match self
1008 .eval_with_return(&code, params.webview_label.as_deref())
1009 .await
1010 {
1011 Ok(result) => self.redact_result(result),
1012 Err(e) => tool_error(format!("invoke_command failed: {e}")),
1013 }
1014 }
1015
1016 #[tool(
1017 description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Currently supported on Windows; other platforms return an error."
1018 )]
1019 async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
1020 if !self.state.privacy.is_tool_enabled("screenshot") {
1021 return tool_disabled("screenshot");
1022 }
1023 match self
1024 .bridge
1025 .get_native_handle(params.window_label.as_deref())
1026 {
1027 Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
1028 Ok(png_bytes) => {
1029 use base64::Engine;
1030 let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
1031 CallToolResult::success(vec![Content::image(b64, "image/png")])
1032 }
1033 Err(e) => tool_error(format!("screenshot capture failed: {e}")),
1034 },
1035 Err(e) => tool_error(format!("cannot get window handle: {e}")),
1036 }
1037 }
1038
1039 #[tool(
1040 description = "Press a keyboard key on the currently focused element. Useful for triggering keyboard shortcuts, submitting forms (Enter), closing dialogs (Escape), or navigating (Tab, ArrowDown)."
1041 )]
1042 async fn press_key(&self, Parameters(params): Parameters<PressKeyParams>) -> CallToolResult {
1043 let code = format!(
1044 "return window.__VICTAURI__?.pressKey({})",
1045 js_string(¶ms.key)
1046 );
1047 match self
1048 .eval_with_return(&code, params.webview_label.as_deref())
1049 .await
1050 {
1051 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1052 Err(e) => tool_error(e),
1053 }
1054 }
1055
1056 #[tool(
1057 description = "Get captured console logs (log, warn, error, info) from the webview. Useful for debugging and monitoring application behavior."
1058 )]
1059 async fn get_console_logs(
1060 &self,
1061 Parameters(params): Parameters<GetConsoleLogsParams>,
1062 ) -> CallToolResult {
1063 let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
1064 let code = if since_arg.is_empty() {
1065 "return window.__VICTAURI__?.getConsoleLogs()".to_string()
1066 } else {
1067 format!("return window.__VICTAURI__?.getConsoleLogs({since_arg})")
1068 };
1069 match self
1070 .eval_with_return(&code, params.webview_label.as_deref())
1071 .await
1072 {
1073 Ok(result) => self.redact_result(result),
1074 Err(e) => tool_error(e),
1075 }
1076 }
1077
1078 #[tool(description = "Double-click an element by its ref handle ID from a DOM snapshot.")]
1081 async fn double_click(
1082 &self,
1083 Parameters(params): Parameters<DoubleClickParams>,
1084 ) -> CallToolResult {
1085 let code = format!(
1086 "return window.__VICTAURI__?.doubleClick({})",
1087 js_string(¶ms.ref_id)
1088 );
1089 match self
1090 .eval_with_return(&code, params.webview_label.as_deref())
1091 .await
1092 {
1093 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1094 Err(e) => tool_error(e),
1095 }
1096 }
1097
1098 #[tool(
1099 description = "Hover over an element by its ref handle ID. Dispatches mouseenter and mouseover events."
1100 )]
1101 async fn hover(&self, Parameters(params): Parameters<HoverParams>) -> CallToolResult {
1102 let code = format!(
1103 "return window.__VICTAURI__?.hover({})",
1104 js_string(¶ms.ref_id)
1105 );
1106 match self
1107 .eval_with_return(&code, params.webview_label.as_deref())
1108 .await
1109 {
1110 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1111 Err(e) => tool_error(e),
1112 }
1113 }
1114
1115 #[tool(
1116 description = "Select one or more options in a <select> element by their option values."
1117 )]
1118 async fn select_option(
1119 &self,
1120 Parameters(params): Parameters<SelectOptionParams>,
1121 ) -> CallToolResult {
1122 let values_json =
1123 serde_json::to_string(¶ms.values).unwrap_or_else(|_| "[]".to_string());
1124 let code = format!(
1125 "return window.__VICTAURI__?.selectOption({}, {})",
1126 js_string(¶ms.ref_id),
1127 values_json
1128 );
1129 match self
1130 .eval_with_return(&code, params.webview_label.as_deref())
1131 .await
1132 {
1133 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1134 Err(e) => tool_error(e),
1135 }
1136 }
1137
1138 #[tool(
1139 description = "Scroll to an element by ref handle ID (scrolls into view), or to absolute page coordinates if no ref given."
1140 )]
1141 async fn scroll_to(&self, Parameters(params): Parameters<ScrollToParams>) -> CallToolResult {
1142 let ref_arg = params
1143 .ref_id
1144 .as_ref()
1145 .map(|r| js_string(r))
1146 .unwrap_or_else(|| "null".to_string());
1147 let x = params.x.unwrap_or(0.0);
1148 let y = params.y.unwrap_or(0.0);
1149 let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1150 match self
1151 .eval_with_return(&code, params.webview_label.as_deref())
1152 .await
1153 {
1154 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1155 Err(e) => tool_error(e),
1156 }
1157 }
1158
1159 #[tool(description = "Focus an element by its ref handle ID.")]
1160 async fn focus_element(
1161 &self,
1162 Parameters(params): Parameters<FocusElementParams>,
1163 ) -> CallToolResult {
1164 let code = format!(
1165 "return window.__VICTAURI__?.focusElement({})",
1166 js_string(¶ms.ref_id)
1167 );
1168 match self
1169 .eval_with_return(&code, params.webview_label.as_deref())
1170 .await
1171 {
1172 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1173 Err(e) => tool_error(e),
1174 }
1175 }
1176
1177 #[tool(
1180 description = "Get intercepted network requests (fetch and XMLHttpRequest). Filter by URL substring and limit the number of results."
1181 )]
1182 async fn get_network_log(
1183 &self,
1184 Parameters(params): Parameters<NetworkLogParams>,
1185 ) -> CallToolResult {
1186 let filter_arg = params
1187 .filter
1188 .as_ref()
1189 .map(|f| js_string(f))
1190 .unwrap_or_else(|| "null".to_string());
1191 let limit_arg = params
1192 .limit
1193 .map(|l| l.to_string())
1194 .unwrap_or_else(|| "null".to_string());
1195 let code = format!("return window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit_arg})");
1196 match self
1197 .eval_with_return(&code, params.webview_label.as_deref())
1198 .await
1199 {
1200 Ok(result) => self.redact_result(result),
1201 Err(e) => tool_error(e),
1202 }
1203 }
1204
1205 #[tool(
1208 description = "Read from localStorage or sessionStorage. Returns all entries if no key is specified, or the value for a specific key."
1209 )]
1210 async fn get_storage(
1211 &self,
1212 Parameters(params): Parameters<GetStorageParams>,
1213 ) -> CallToolResult {
1214 let method = match params.storage_type.as_str() {
1215 "session" => "getSessionStorage",
1216 _ => "getLocalStorage",
1217 };
1218 let key_arg = params
1219 .key
1220 .as_ref()
1221 .map(|k| js_string(k))
1222 .unwrap_or_default();
1223 let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1224 match self
1225 .eval_with_return(&code, params.webview_label.as_deref())
1226 .await
1227 {
1228 Ok(result) => self.redact_result(result),
1229 Err(e) => tool_error(e),
1230 }
1231 }
1232
1233 #[tool(description = "Set a value in localStorage or sessionStorage.")]
1234 async fn set_storage(
1235 &self,
1236 Parameters(params): Parameters<SetStorageParams>,
1237 ) -> CallToolResult {
1238 if !self.state.privacy.is_tool_enabled("set_storage") {
1239 return tool_disabled("set_storage");
1240 }
1241 let method = match params.storage_type.as_str() {
1242 "session" => "setSessionStorage",
1243 _ => "setLocalStorage",
1244 };
1245 let value_json =
1246 serde_json::to_string(¶ms.value).unwrap_or_else(|_| "null".to_string());
1247 let code = format!(
1248 "return window.__VICTAURI__?.{method}({}, {value_json})",
1249 js_string(¶ms.key)
1250 );
1251 match self
1252 .eval_with_return(&code, params.webview_label.as_deref())
1253 .await
1254 {
1255 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1256 Err(e) => tool_error(e),
1257 }
1258 }
1259
1260 #[tool(description = "Delete a key from localStorage or sessionStorage.")]
1261 async fn delete_storage(
1262 &self,
1263 Parameters(params): Parameters<DeleteStorageParams>,
1264 ) -> CallToolResult {
1265 if !self.state.privacy.is_tool_enabled("delete_storage") {
1266 return tool_disabled("delete_storage");
1267 }
1268 let method = match params.storage_type.as_str() {
1269 "session" => "deleteSessionStorage",
1270 _ => "deleteLocalStorage",
1271 };
1272 let code = format!(
1273 "return window.__VICTAURI__?.{method}({})",
1274 js_string(¶ms.key)
1275 );
1276 match self
1277 .eval_with_return(&code, params.webview_label.as_deref())
1278 .await
1279 {
1280 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1281 Err(e) => tool_error(e),
1282 }
1283 }
1284
1285 #[tool(description = "Get all cookies visible to the webview document.")]
1286 async fn get_cookies(
1287 &self,
1288 Parameters(params): Parameters<GetCookiesParams>,
1289 ) -> CallToolResult {
1290 let code = "return window.__VICTAURI__?.getCookies()";
1291 match self
1292 .eval_with_return(code, params.webview_label.as_deref())
1293 .await
1294 {
1295 Ok(result) => self.redact_result(result),
1296 Err(e) => tool_error(e),
1297 }
1298 }
1299
1300 #[tool(
1303 description = "Get the navigation history log — tracks pushState, replaceState, popstate, hashchange, and the initial page load."
1304 )]
1305 async fn get_navigation_log(
1306 &self,
1307 Parameters(params): Parameters<NavigationLogParams>,
1308 ) -> CallToolResult {
1309 let code = "return window.__VICTAURI__?.getNavigationLog()";
1310 match self
1311 .eval_with_return(code, params.webview_label.as_deref())
1312 .await
1313 {
1314 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1315 Err(e) => tool_error(e),
1316 }
1317 }
1318
1319 #[tool(
1320 description = "Navigate the webview to a URL. Blocks javascript:, data:, and vbscript: URLs."
1321 )]
1322 async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1323 if !self.state.privacy.is_tool_enabled("navigate") {
1324 return tool_disabled("navigate");
1325 }
1326 if let Err(e) = validate_url(¶ms.url) {
1327 return tool_error(e);
1328 }
1329 let code = format!(
1330 "return window.__VICTAURI__?.navigate({})",
1331 js_string(¶ms.url)
1332 );
1333 match self
1334 .eval_with_return(&code, params.webview_label.as_deref())
1335 .await
1336 {
1337 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1338 Err(e) => tool_error(e),
1339 }
1340 }
1341
1342 #[tool(description = "Navigate back in the webview's browser history.")]
1343 async fn navigate_back(
1344 &self,
1345 Parameters(params): Parameters<NavigationLogParams>,
1346 ) -> CallToolResult {
1347 let code = "return window.__VICTAURI__?.navigateBack()";
1348 match self
1349 .eval_with_return(code, params.webview_label.as_deref())
1350 .await
1351 {
1352 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1353 Err(e) => tool_error(e),
1354 }
1355 }
1356
1357 #[tool(
1360 description = "Get captured dialog events (alert, confirm, prompt). Dialogs are auto-handled: alert is accepted, confirm returns true, prompt returns default value."
1361 )]
1362 async fn get_dialog_log(
1363 &self,
1364 Parameters(params): Parameters<DialogLogParams>,
1365 ) -> CallToolResult {
1366 let code = "return window.__VICTAURI__?.getDialogLog()";
1367 match self
1368 .eval_with_return(code, params.webview_label.as_deref())
1369 .await
1370 {
1371 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1372 Err(e) => tool_error(e),
1373 }
1374 }
1375
1376 #[tool(
1377 description = "Configure automatic responses for browser dialogs. Types: alert, confirm, prompt. Actions: accept, dismiss. For prompt dialogs, optionally set the response text."
1378 )]
1379 async fn set_dialog_response(
1380 &self,
1381 Parameters(params): Parameters<SetDialogResponseParams>,
1382 ) -> CallToolResult {
1383 if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1384 return tool_disabled("set_dialog_response");
1385 }
1386 let text_arg = params
1387 .text
1388 .as_ref()
1389 .map(|t| js_string(t))
1390 .unwrap_or_else(|| "undefined".to_string());
1391 let code = format!(
1392 "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1393 js_string(¶ms.dialog_type),
1394 js_string(¶ms.action)
1395 );
1396 match self
1397 .eval_with_return(&code, params.webview_label.as_deref())
1398 .await
1399 {
1400 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1401 Err(e) => tool_error(e),
1402 }
1403 }
1404
1405 #[tool(
1408 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)."
1409 )]
1410 async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
1411 let value = params
1412 .value
1413 .as_ref()
1414 .map(|v| js_string(v))
1415 .unwrap_or_else(|| "null".to_string());
1416 let timeout_ms = params.timeout_ms.unwrap_or(10000);
1417 let poll = params.poll_ms.unwrap_or(200);
1418 let code = format!(
1419 "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
1420 js_string(¶ms.condition)
1421 );
1422 let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
1426 match self
1427 .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
1428 .await
1429 {
1430 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1431 Err(e) => tool_error(e),
1432 }
1433 }
1434
1435 #[tool(
1438 description = "Manage a window: minimize, unminimize, maximize, unmaximize, close, focus, show, hide, fullscreen, unfullscreen, always_on_top, not_always_on_top."
1439 )]
1440 async fn manage_window(
1441 &self,
1442 Parameters(params): Parameters<ManageWindowParams>,
1443 ) -> CallToolResult {
1444 match self
1445 .bridge
1446 .manage_window(params.label.as_deref(), ¶ms.action)
1447 {
1448 Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1449 Err(e) => tool_error(e),
1450 }
1451 }
1452
1453 #[tool(description = "Resize a window to the specified width and height in logical pixels.")]
1454 async fn resize_window(
1455 &self,
1456 Parameters(params): Parameters<ResizeWindowParams>,
1457 ) -> CallToolResult {
1458 match self
1459 .bridge
1460 .resize_window(params.label.as_deref(), params.width, params.height)
1461 {
1462 Ok(()) => {
1463 let result =
1464 serde_json::json!({"ok": true, "width": params.width, "height": params.height});
1465 CallToolResult::success(vec![Content::text(result.to_string())])
1466 }
1467 Err(e) => tool_error(e),
1468 }
1469 }
1470
1471 #[tool(
1472 description = "Move a window to the specified screen position (x, y) in logical pixels."
1473 )]
1474 async fn move_window(
1475 &self,
1476 Parameters(params): Parameters<MoveWindowParams>,
1477 ) -> CallToolResult {
1478 match self
1479 .bridge
1480 .move_window(params.label.as_deref(), params.x, params.y)
1481 {
1482 Ok(()) => {
1483 let result = serde_json::json!({"ok": true, "x": params.x, "y": params.y});
1484 CallToolResult::success(vec![Content::text(result.to_string())])
1485 }
1486 Err(e) => tool_error(e),
1487 }
1488 }
1489
1490 #[tool(description = "Set the title of a window.")]
1491 async fn set_window_title(
1492 &self,
1493 Parameters(params): Parameters<SetWindowTitleParams>,
1494 ) -> CallToolResult {
1495 match self
1496 .bridge
1497 .set_window_title(params.label.as_deref(), ¶ms.title)
1498 {
1499 Ok(()) => {
1500 let result = serde_json::json!({"ok": true, "title": params.title});
1501 CallToolResult::success(vec![Content::text(result.to_string())])
1502 }
1503 Err(e) => tool_error(e),
1504 }
1505 }
1506
1507 #[tool(
1510 description = "Get computed CSS styles for an element. Returns key properties by default, or specific properties if listed."
1511 )]
1512 async fn get_styles(&self, Parameters(params): Parameters<GetStylesParams>) -> CallToolResult {
1513 let props_arg = match ¶ms.properties {
1514 Some(props) => {
1515 let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1516 format!("[{}]", arr.join(","))
1517 }
1518 None => "null".to_string(),
1519 };
1520 let code = format!(
1521 "return window.__VICTAURI__?.getStyles({}, {})",
1522 js_string(¶ms.ref_id),
1523 props_arg
1524 );
1525 match self
1526 .eval_with_return(&code, params.webview_label.as_deref())
1527 .await
1528 {
1529 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1530 Err(e) => tool_error(e),
1531 }
1532 }
1533
1534 #[tool(
1535 description = "Get precise bounding boxes with CSS box model (margin, padding, border) for one or more elements."
1536 )]
1537 async fn get_bounding_boxes(
1538 &self,
1539 Parameters(params): Parameters<GetBoundingBoxesParams>,
1540 ) -> CallToolResult {
1541 let refs: Vec<String> = params.ref_ids.iter().map(|r| js_string(r)).collect();
1542 let code = format!(
1543 "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1544 refs.join(",")
1545 );
1546 match self
1547 .eval_with_return(&code, params.webview_label.as_deref())
1548 .await
1549 {
1550 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1551 Err(e) => tool_error(e),
1552 }
1553 }
1554
1555 #[tool(
1556 description = "Draw a colored overlay on an element for visual debugging. The overlay is fixed-position and non-interactive."
1557 )]
1558 async fn highlight_element(
1559 &self,
1560 Parameters(params): Parameters<HighlightElementParams>,
1561 ) -> CallToolResult {
1562 let color_arg = match ¶ms.color {
1563 Some(c) => match sanitize_css_color(c) {
1564 Ok(safe) => format!("\"{}\"", safe),
1565 Err(e) => return tool_error(e),
1566 },
1567 None => "null".to_string(),
1568 };
1569 let label_arg = match ¶ms.label {
1570 Some(l) => js_string(l),
1571 None => "null".to_string(),
1572 };
1573 let code = format!(
1574 "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1575 js_string(¶ms.ref_id),
1576 color_arg,
1577 label_arg
1578 );
1579 match self
1580 .eval_with_return(&code, params.webview_label.as_deref())
1581 .await
1582 {
1583 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1584 Err(e) => tool_error(e),
1585 }
1586 }
1587
1588 #[tool(description = "Remove all debug highlight overlays from the page.")]
1589 async fn clear_highlights(
1590 &self,
1591 Parameters(params): Parameters<ClearHighlightsParams>,
1592 ) -> CallToolResult {
1593 let code = "return window.__VICTAURI__?.clearHighlights()";
1594 match self
1595 .eval_with_return(code, params.webview_label.as_deref())
1596 .await
1597 {
1598 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1599 Err(e) => tool_error(e),
1600 }
1601 }
1602
1603 #[tool(
1604 description = "Inject custom CSS into the page. Replaces any previously injected CSS. Useful for debugging layout issues or prototyping style changes."
1605 )]
1606 async fn inject_css(&self, Parameters(params): Parameters<InjectCssParams>) -> CallToolResult {
1607 if !self.state.privacy.is_tool_enabled("inject_css") {
1608 return tool_disabled("inject_css");
1609 }
1610 let code = format!(
1611 "return window.__VICTAURI__?.injectCss({})",
1612 js_string(¶ms.css)
1613 );
1614 match self
1615 .eval_with_return(&code, params.webview_label.as_deref())
1616 .await
1617 {
1618 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1619 Err(e) => tool_error(e),
1620 }
1621 }
1622
1623 #[tool(description = "Remove previously injected CSS from the page.")]
1624 async fn remove_injected_css(
1625 &self,
1626 Parameters(params): Parameters<RemoveInjectedCssParams>,
1627 ) -> CallToolResult {
1628 let code = "return window.__VICTAURI__?.removeInjectedCss()";
1629 match self
1630 .eval_with_return(code, params.webview_label.as_deref())
1631 .await
1632 {
1633 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1634 Err(e) => tool_error(e),
1635 }
1636 }
1637
1638 #[tool(
1639 description = "Run a comprehensive accessibility audit. Checks for missing alt text, unlabeled form inputs, empty buttons/links, heading hierarchy, color contrast, ARIA role validity, and more. Returns violations and warnings with severity levels."
1640 )]
1641 async fn audit_accessibility(
1642 &self,
1643 Parameters(params): Parameters<AuditAccessibilityParams>,
1644 ) -> CallToolResult {
1645 let code = "return window.__VICTAURI__?.auditAccessibility()";
1646 match self
1647 .eval_with_return(code, params.webview_label.as_deref())
1648 .await
1649 {
1650 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1651 Err(e) => tool_error(e),
1652 }
1653 }
1654
1655 #[tool(
1656 description = "Get performance metrics: navigation timing, resource loading summary, paint timing, JS heap usage, long task count, and DOM statistics."
1657 )]
1658 async fn get_performance_metrics(
1659 &self,
1660 Parameters(params): Parameters<GetPerformanceMetricsParams>,
1661 ) -> CallToolResult {
1662 let code = "return window.__VICTAURI__?.getPerformanceMetrics()";
1663 match self
1664 .eval_with_return(code, params.webview_label.as_deref())
1665 .await
1666 {
1667 Ok(result) => CallToolResult::success(vec![Content::text(result)]),
1668 Err(e) => tool_error(e),
1669 }
1670 }
1671
1672 #[tool(
1673 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."
1674 )]
1675 async fn get_plugin_info(&self) -> CallToolResult {
1676 let disabled: Vec<&str> = self
1677 .state
1678 .privacy
1679 .disabled_tools
1680 .iter()
1681 .map(|s| s.as_str())
1682 .collect();
1683 let blocklist: Vec<&str> = self
1684 .state
1685 .privacy
1686 .command_blocklist
1687 .iter()
1688 .map(|s| s.as_str())
1689 .collect();
1690 let allowlist: Option<Vec<&str>> = self
1691 .state
1692 .privacy
1693 .command_allowlist
1694 .as_ref()
1695 .map(|s| s.iter().map(|s| s.as_str()).collect());
1696 let all_tools = Self::tool_router().list_all();
1697 let enabled_tools: Vec<&str> = all_tools
1698 .iter()
1699 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1700 .map(|t| t.name.as_ref())
1701 .collect();
1702
1703 let result = serde_json::json!({
1704 "version": env!("CARGO_PKG_VERSION"),
1705 "bridge_version": BRIDGE_VERSION,
1706 "port": self.state.port,
1707 "tools": {
1708 "total": all_tools.len(),
1709 "enabled": enabled_tools.len(),
1710 "enabled_list": enabled_tools,
1711 "disabled_list": disabled,
1712 },
1713 "commands": {
1714 "allowlist": allowlist,
1715 "blocklist": blocklist,
1716 },
1717 "privacy": {
1718 "redaction_enabled": self.state.privacy.redaction_enabled,
1719 },
1720 "capacities": {
1721 "event_log": self.state.event_log.capacity(),
1722 "eval_timeout_secs": self.state.eval_timeout.as_secs(),
1723 },
1724 "registered_commands": self.state.registry.count(),
1725 "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
1726 "uptime_secs": self.state.started_at.elapsed().as_secs(),
1727 });
1728 match serde_json::to_string_pretty(&result) {
1729 Ok(json) => CallToolResult::success(vec![Content::text(json)]),
1730 Err(e) => tool_error(e.to_string()),
1731 }
1732 }
1733}
1734
1735impl VictauriMcpHandler {
1736 pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
1737 Self {
1738 state,
1739 bridge,
1740 subscriptions: Arc::new(Mutex::new(HashSet::new())),
1741 bridge_checked: Arc::new(AtomicBool::new(false)),
1742 }
1743 }
1744
1745 fn redact_result(&self, output: String) -> CallToolResult {
1746 let redacted = self.state.privacy.redact_output(&output);
1747 CallToolResult::success(vec![Content::text(redacted)])
1748 }
1749
1750 async fn eval_with_return(
1751 &self,
1752 code: &str,
1753 webview_label: Option<&str>,
1754 ) -> Result<String, String> {
1755 self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
1756 .await
1757 }
1758
1759 async fn eval_with_return_timeout(
1760 &self,
1761 code: &str,
1762 webview_label: Option<&str>,
1763 timeout: std::time::Duration,
1764 ) -> Result<String, String> {
1765 let id = uuid::Uuid::new_v4().to_string();
1766 let (tx, rx) = tokio::sync::oneshot::channel();
1767
1768 {
1769 let mut pending = self.state.pending_evals.lock().await;
1770 if pending.len() >= MAX_PENDING_EVALS {
1771 return Err(format!(
1772 "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
1773 ));
1774 }
1775 pending.insert(id.clone(), tx);
1776 }
1777
1778 let code = code.trim();
1782 let needs_return = !code.starts_with("return ")
1783 && !code.starts_with("return;")
1784 && !code.starts_with('{')
1785 && !code.starts_with("if ")
1786 && !code.starts_with("if(")
1787 && !code.starts_with("for ")
1788 && !code.starts_with("for(")
1789 && !code.starts_with("while ")
1790 && !code.starts_with("while(")
1791 && !code.starts_with("switch ")
1792 && !code.starts_with("try ")
1793 && !code.starts_with("const ")
1794 && !code.starts_with("let ")
1795 && !code.starts_with("var ")
1796 && !code.starts_with("function ")
1797 && !code.starts_with("class ")
1798 && !code.starts_with("throw ");
1799 let code = if needs_return {
1800 format!("return {code}")
1801 } else {
1802 code.to_string()
1803 };
1804
1805 let inject = format!(
1806 r#"
1807 (async () => {{
1808 try {{
1809 const __result = await (async () => {{ {code} }})();
1810 await window.__TAURI__.core.invoke('plugin:victauri|victauri_eval_callback', {{
1811 id: '{id}',
1812 result: JSON.stringify(__result)
1813 }});
1814 }} catch (e) {{
1815 await window.__TAURI__.core.invoke('plugin:victauri|victauri_eval_callback', {{
1816 id: '{id}',
1817 result: JSON.stringify({{ __error: e.message }})
1818 }});
1819 }}
1820 }})();
1821 "#
1822 );
1823
1824 if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
1825 self.state.pending_evals.lock().await.remove(&id);
1826 return Err(format!("eval injection failed: {e}"));
1827 }
1828
1829 match tokio::time::timeout(timeout, rx).await {
1830 Ok(Ok(result)) => {
1831 self.check_bridge_version_once();
1832 Ok(result)
1833 }
1834 Ok(Err(_)) => Err("eval callback channel closed".to_string()),
1835 Err(_) => {
1836 self.state.pending_evals.lock().await.remove(&id);
1837 Err(format!("eval timed out after {}s", timeout.as_secs()))
1838 }
1839 }
1840 }
1841
1842 fn check_bridge_version_once(&self) {
1843 if self.bridge_checked.swap(true, Ordering::Relaxed) {
1844 return;
1845 }
1846 let handler = self.clone();
1847 tokio::spawn(async move {
1848 match handler
1849 .eval_with_return_timeout(
1850 "window.__VICTAURI__?.version",
1851 None,
1852 std::time::Duration::from_secs(5),
1853 )
1854 .await
1855 {
1856 Ok(v) => {
1857 let v = v.trim_matches('"');
1858 if v != BRIDGE_VERSION {
1859 tracing::warn!(
1860 "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
1861 );
1862 } else {
1863 tracing::debug!("Bridge version verified: {v}");
1864 }
1865 }
1866 Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
1867 }
1868 });
1869 }
1870}
1871
1872const SERVER_INSTRUCTIONS: &str = "Victauri gives you X-ray vision and hands inside a running Tauri application. \
1873You can evaluate JS, snapshot the DOM, interact with elements (click, double-click, \
1874hover, fill, type, select, scroll, focus), press keys, invoke Tauri commands, \
1875capture screenshots, manage windows (minimize, maximize, resize, move, close), \
1876view IPC and network traffic, read/write browser storage, track navigation history, \
1877handle dialogs, wait for conditions, search the command registry, monitor process memory, \
1878record and replay sessions, inspect computed CSS styles, measure element bounding boxes, \
1879draw debug overlays on elements, inject custom CSS, run accessibility audits (alt text, \
1880labels, contrast, ARIA, heading hierarchy), get performance metrics (navigation timing, \
1881resource loading, JS heap, long tasks, DOM stats), and subscribe to live resource \
1882streams — all through MCP.";
1883
1884impl ServerHandler for VictauriMcpHandler {
1885 fn get_info(&self) -> ServerInfo {
1886 ServerInfo::new(
1887 ServerCapabilities::builder()
1888 .enable_tools()
1889 .enable_resources()
1890 .enable_resources_subscribe()
1891 .build(),
1892 )
1893 .with_instructions(SERVER_INSTRUCTIONS)
1894 }
1895
1896 async fn list_tools(
1897 &self,
1898 _request: Option<PaginatedRequestParams>,
1899 _context: RequestContext<RoleServer>,
1900 ) -> Result<ListToolsResult, ErrorData> {
1901 let all_tools = Self::tool_router().list_all();
1902 let filtered: Vec<Tool> = all_tools
1903 .into_iter()
1904 .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1905 .collect();
1906 Ok(ListToolsResult {
1907 tools: filtered,
1908 ..Default::default()
1909 })
1910 }
1911
1912 async fn call_tool(
1913 &self,
1914 request: CallToolRequestParams,
1915 context: RequestContext<RoleServer>,
1916 ) -> Result<CallToolResult, ErrorData> {
1917 let tool_name: String = request.name.as_ref().to_owned();
1918 if !self.state.privacy.is_tool_enabled(&tool_name) {
1919 tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
1920 return Ok(tool_disabled(&tool_name));
1921 }
1922 self.state
1923 .tool_invocations
1924 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1925 let start = std::time::Instant::now();
1926 tracing::debug!(tool = %tool_name, "tool invocation started");
1927 let ctx = ToolCallContext::new(self, request, context);
1928 let result = Self::tool_router().call(ctx).await;
1929 let elapsed = start.elapsed();
1930 tracing::debug!(
1931 tool = %tool_name,
1932 elapsed_ms = elapsed.as_millis() as u64,
1933 is_error = result.as_ref().map(|r| r.is_error.unwrap_or(false)).unwrap_or(true),
1934 "tool invocation completed"
1935 );
1936 result
1937 }
1938
1939 fn get_tool(&self, name: &str) -> Option<Tool> {
1940 if !self.state.privacy.is_tool_enabled(name) {
1941 return None;
1942 }
1943 Self::tool_router().get(name).cloned()
1944 }
1945
1946 async fn list_resources(
1947 &self,
1948 _request: Option<PaginatedRequestParams>,
1949 _context: RequestContext<RoleServer>,
1950 ) -> Result<ListResourcesResult, ErrorData> {
1951 Ok(ListResourcesResult {
1952 resources: vec![
1953 RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
1954 .with_description(
1955 "Live IPC call log — all commands invoked between frontend and backend",
1956 )
1957 .with_mime_type("application/json")
1958 .no_annotation(),
1959 RawResource::new(RESOURCE_URI_WINDOWS, "windows")
1960 .with_description(
1961 "Current state of all Tauri windows — position, size, visibility, focus",
1962 )
1963 .with_mime_type("application/json")
1964 .no_annotation(),
1965 RawResource::new(RESOURCE_URI_STATE, "state")
1966 .with_description(
1967 "Victauri plugin state — event count, registered commands, memory stats",
1968 )
1969 .with_mime_type("application/json")
1970 .no_annotation(),
1971 ],
1972 ..Default::default()
1973 })
1974 }
1975
1976 async fn read_resource(
1977 &self,
1978 request: ReadResourceRequestParams,
1979 _context: RequestContext<RoleServer>,
1980 ) -> Result<ReadResourceResult, ErrorData> {
1981 let uri = &request.uri;
1982 let json = match uri.as_str() {
1983 RESOURCE_URI_IPC_LOG => {
1984 let calls = self.state.event_log.ipc_calls();
1985 serde_json::to_string_pretty(&calls)
1986 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1987 }
1988 RESOURCE_URI_WINDOWS => {
1989 let states = self.bridge.get_window_states(None);
1990 serde_json::to_string_pretty(&states)
1991 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
1992 }
1993 RESOURCE_URI_STATE => {
1994 let state_json = serde_json::json!({
1995 "events_captured": self.state.event_log.len(),
1996 "commands_registered": self.state.registry.count(),
1997 "memory": crate::memory::current_stats(),
1998 "port": self.state.port,
1999 });
2000 serde_json::to_string_pretty(&state_json)
2001 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
2002 }
2003 _ => {
2004 return Err(ErrorData::resource_not_found(
2005 format!("unknown resource: {uri}"),
2006 None,
2007 ));
2008 }
2009 };
2010
2011 Ok(ReadResourceResult::new(vec![ResourceContents::text(
2012 json, uri,
2013 )]))
2014 }
2015
2016 async fn subscribe(
2017 &self,
2018 request: SubscribeRequestParams,
2019 _context: RequestContext<RoleServer>,
2020 ) -> Result<(), ErrorData> {
2021 let uri = &request.uri;
2022 match uri.as_str() {
2023 RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
2024 self.subscriptions.lock().await.insert(uri.clone());
2025 tracing::info!("Client subscribed to resource: {uri}");
2026 Ok(())
2027 }
2028 _ => Err(ErrorData::resource_not_found(
2029 format!("unknown resource: {uri}"),
2030 None,
2031 )),
2032 }
2033 }
2034
2035 async fn unsubscribe(
2036 &self,
2037 request: UnsubscribeRequestParams,
2038 _context: RequestContext<RoleServer>,
2039 ) -> Result<(), ErrorData> {
2040 self.subscriptions.lock().await.remove(&request.uri);
2041 tracing::info!("Client unsubscribed from resource: {}", request.uri);
2042 Ok(())
2043 }
2044}
2045
2046fn tool_error(msg: impl Into<String>) -> CallToolResult {
2047 let mut result = CallToolResult::success(vec![Content::text(msg)]);
2048 result.is_error = Some(true);
2049 result
2050}
2051
2052fn tool_disabled(name: &str) -> CallToolResult {
2053 tool_error(format!(
2054 "tool '{name}' is disabled by privacy configuration"
2055 ))
2056}
2057
2058fn validate_url(url: &str) -> Result<(), String> {
2059 let trimmed: String = url.chars().filter(|c| !c.is_control()).collect();
2060 match url::Url::parse(&trimmed) {
2061 Ok(parsed) => match parsed.scheme() {
2062 "http" | "https" | "file" => Ok(()),
2063 scheme => Err(format!(
2064 "scheme '{scheme}' is not allowed; use http, https, or file"
2065 )),
2066 },
2067 Err(e) => Err(format!("invalid URL: {e}")),
2068 }
2069}
2070
2071fn sanitize_css_color(color: &str) -> Result<String, String> {
2072 let s = color.trim();
2073 if s.len() > 100 {
2074 return Err("CSS color value too long".to_string());
2075 }
2076 if s.contains('\\') {
2078 return Err("CSS escape sequences not allowed in color values".to_string());
2079 }
2080 let valid = s
2081 .chars()
2082 .all(|c| c.is_alphanumeric() || matches!(c, '#' | '(' | ')' | ',' | '.' | ' ' | '%' | '-'));
2083 if !valid {
2084 return Err("invalid characters in CSS color value".to_string());
2085 }
2086 if s.contains(';')
2087 || s.contains('{')
2088 || s.contains('}')
2089 || s.to_lowercase().contains("url(")
2090 || s.to_lowercase().contains("expression(")
2091 || s.to_lowercase().contains("import")
2092 {
2093 return Err("invalid CSS color value".to_string());
2094 }
2095 Ok(s.to_string())
2096}
2097
2098pub fn build_app(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> axum::Router {
2101 build_app_with_options(state, bridge, None)
2102}
2103
2104pub fn build_app_with_options(
2105 state: Arc<VictauriState>,
2106 bridge: Arc<dyn WebviewBridge>,
2107 auth_token: Option<String>,
2108) -> axum::Router {
2109 let handler = VictauriMcpHandler::new(state.clone(), bridge);
2110
2111 let mcp_service = StreamableHttpService::new(
2112 move || Ok(handler.clone()),
2113 Arc::new(LocalSessionManager::default()),
2114 StreamableHttpServerConfig::default(),
2115 );
2116
2117 let auth_state = Arc::new(crate::auth::AuthState {
2118 token: auth_token.clone(),
2119 });
2120 let health_state = state.clone();
2121 let info_state = state.clone();
2122 let info_auth = auth_token.is_some();
2123
2124 let privacy_enabled = !state.privacy.disabled_tools.is_empty()
2125 || state.privacy.command_allowlist.is_some()
2126 || !state.privacy.command_blocklist.is_empty()
2127 || state.privacy.redaction_enabled;
2128
2129 let mut router = axum::Router::new()
2130 .route_service("/mcp", mcp_service)
2131 .route(
2132 "/info",
2133 axum::routing::get(move || {
2134 let s = info_state.clone();
2135 async move {
2136 axum::Json(serde_json::json!({
2137 "name": "victauri",
2138 "version": env!("CARGO_PKG_VERSION"),
2139 "protocol": "mcp",
2140 "commands_registered": s.registry.count(),
2141 "events_captured": s.event_log.len(),
2142 "port": s.port,
2143 "auth_required": info_auth,
2144 "privacy_mode": privacy_enabled,
2145 }))
2146 }
2147 }),
2148 );
2149
2150 if auth_token.is_some() {
2151 router = router.layer(axum::middleware::from_fn_with_state(
2152 auth_state,
2153 crate::auth::require_auth,
2154 ));
2155 }
2156
2157 let rate_limiter = crate::auth::default_rate_limiter();
2158 router = router.layer(axum::middleware::from_fn_with_state(
2159 rate_limiter,
2160 crate::auth::rate_limit,
2161 ));
2162
2163 router
2164 .route(
2165 "/health",
2166 axum::routing::get(move || {
2167 let s = health_state.clone();
2168 async move {
2169 axum::Json(serde_json::json!({
2170 "status": "ok",
2171 "uptime_secs": s.started_at.elapsed().as_secs(),
2172 "events_captured": s.event_log.len(),
2173 "commands_registered": s.registry.count(),
2174 "memory": crate::memory::current_stats(),
2175 }))
2176 }
2177 }),
2178 )
2179 .layer(axum::middleware::from_fn(crate::auth::security_headers))
2180 .layer(axum::middleware::from_fn(crate::auth::origin_guard))
2181 .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
2182}
2183
2184pub mod tests_support {
2185 pub fn get_memory_stats() -> serde_json::Value {
2186 crate::memory::current_stats()
2187 }
2188}
2189
2190pub async fn start_server<R: Runtime>(
2191 app_handle: tauri::AppHandle<R>,
2192 state: Arc<VictauriState>,
2193 port: u16,
2194 shutdown_rx: tokio::sync::watch::Receiver<bool>,
2195) -> anyhow::Result<()> {
2196 start_server_with_options(app_handle, state, port, None, shutdown_rx).await
2197}
2198
2199pub async fn start_server_with_options<R: Runtime>(
2200 app_handle: tauri::AppHandle<R>,
2201 state: Arc<VictauriState>,
2202 port: u16,
2203 auth_token: Option<String>,
2204 mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
2205) -> anyhow::Result<()> {
2206 let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
2207 let app = build_app_with_options(state, bridge, auth_token);
2208
2209 let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await?;
2210 tracing::info!("Victauri MCP server listening on 127.0.0.1:{port}");
2211
2212 axum::serve(listener, app)
2213 .with_graceful_shutdown(async move {
2214 let _ = shutdown_rx.wait_for(|&v| v).await;
2215 tracing::info!("Victauri MCP server shutting down gracefully");
2216 })
2217 .await?;
2218 Ok(())
2219}
2220
2221#[cfg(test)]
2222mod tests {
2223 use super::*;
2224
2225 #[test]
2226 fn js_string_simple() {
2227 assert_eq!(js_string("hello"), "\"hello\"");
2228 }
2229
2230 #[test]
2231 fn js_string_single_quotes() {
2232 let result = js_string("it's a test");
2233 assert!(result.contains("it's a test"));
2234 }
2235
2236 #[test]
2237 fn js_string_double_quotes() {
2238 let result = js_string(r#"say "hello""#);
2239 assert!(result.contains(r#"\""#));
2240 }
2241
2242 #[test]
2243 fn js_string_backslashes() {
2244 let result = js_string(r"path\to\file");
2245 assert!(result.contains(r"\\"));
2246 }
2247
2248 #[test]
2249 fn js_string_newlines_and_tabs() {
2250 let result = js_string("line1\nline2\ttab");
2251 assert!(result.contains(r"\n"));
2252 assert!(result.contains(r"\t"));
2253 assert!(!result.contains('\n'));
2254 }
2255
2256 #[test]
2257 fn js_string_null_bytes() {
2258 let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
2259 let result = js_string(&input);
2260 assert!(result.contains("\\u0000"));
2262 assert!(!result.contains('\0'));
2263 }
2264
2265 #[test]
2266 fn js_string_template_literal_injection() {
2267 let result = js_string("`${alert(1)}`");
2268 assert!(result.starts_with('"'));
2271 assert!(result.ends_with('"'));
2272 }
2273
2274 #[test]
2275 fn js_string_unicode_separators() {
2276 let result = js_string("a\u{2028}b\u{2029}c");
2281 let decoded: String = serde_json::from_str(&result).unwrap();
2283 assert_eq!(decoded, "a\u{2028}b\u{2029}c");
2284 }
2285
2286 #[test]
2287 fn js_string_empty() {
2288 assert_eq!(js_string(""), "\"\"");
2289 }
2290
2291 #[test]
2292 fn js_string_html_script_close() {
2293 let result = js_string("</script><img onerror=alert(1)>");
2295 assert!(result.starts_with('"'));
2296 let decoded: String = serde_json::from_str(&result).unwrap();
2298 assert_eq!(decoded, "</script><img onerror=alert(1)>");
2299 }
2300
2301 #[test]
2302 fn js_string_very_long() {
2303 let long = "a".repeat(100_000);
2304 let result = js_string(&long);
2305 assert!(result.len() >= 100_002); }
2307
2308 #[test]
2311 fn url_allows_http() {
2312 assert!(validate_url("http://example.com").is_ok());
2313 }
2314
2315 #[test]
2316 fn url_allows_https() {
2317 assert!(validate_url("https://example.com/path?q=1").is_ok());
2318 }
2319
2320 #[test]
2321 fn url_allows_file() {
2322 assert!(validate_url("file:///tmp/test.html").is_ok());
2323 }
2324
2325 #[test]
2326 fn url_blocks_javascript() {
2327 assert!(validate_url("javascript:alert(1)").is_err());
2328 }
2329
2330 #[test]
2331 fn url_blocks_javascript_case_insensitive() {
2332 assert!(validate_url("JAVASCRIPT:alert(1)").is_err());
2333 }
2334
2335 #[test]
2336 fn url_blocks_data_scheme() {
2337 assert!(validate_url("data:text/html,<script>alert(1)</script>").is_err());
2338 }
2339
2340 #[test]
2341 fn url_blocks_vbscript() {
2342 assert!(validate_url("vbscript:MsgBox(1)").is_err());
2343 }
2344
2345 #[test]
2346 fn url_rejects_invalid() {
2347 assert!(validate_url("not a url at all").is_err());
2348 }
2349
2350 #[test]
2351 fn url_strips_control_chars() {
2352 let input = format!("http://example{}com", '\0');
2354 assert!(validate_url(&input).is_ok());
2355 }
2356}