1use std::sync::Arc;
2use std::sync::atomic::Ordering;
3
4use axum::extract::DefaultBodyLimit;
5use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
6use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
7use tauri::Runtime;
8use tower::limit::ConcurrencyLimitLayer;
9
10use crate::VictauriState;
11use crate::bridge::WebviewBridge;
12
13use super::{MAX_PENDING_EVALS, VictauriMcpHandler};
14
15const DEFAULT_WEBVIEW_LABEL: &str = "main";
16
17pub fn build_app(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> axum::Router {
21 build_app_with_options(state, bridge, None)
22}
23
24#[must_use]
32fn normalize_auth_token(auth_token: Option<String>) -> Option<String> {
33 match auth_token {
34 Some(t) if t.trim().is_empty() => {
35 tracing::warn!(
36 "Victauri: configured auth token is empty/whitespace — treating as NO auth. \
37 Set a non-empty VICTAURI_AUTH_TOKEN / auth_token(), or use auth_disabled() \
38 to intentionally run without authentication."
39 );
40 None
41 }
42 other => other,
43 }
44}
45
46pub fn build_app_with_options(
48 state: Arc<VictauriState>,
49 bridge: Arc<dyn WebviewBridge>,
50 auth_token: Option<String>,
51) -> axum::Router {
52 build_app_full(state, bridge, auth_token, None)
53}
54
55pub fn build_app_full(
57 state: Arc<VictauriState>,
58 bridge: Arc<dyn WebviewBridge>,
59 auth_token: Option<String>,
60 rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
61) -> axum::Router {
62 let auth_token = normalize_auth_token(auth_token);
65
66 let tauri_cfg = bridge.tauri_config();
69 let app_identifier = tauri_cfg
70 .get("identifier")
71 .and_then(|v| v.as_str())
72 .map(String::from);
73 let app_product_name = tauri_cfg
74 .get("product_name")
75 .and_then(|v| v.as_str())
76 .map(String::from);
77
78 let handler = VictauriMcpHandler::new(state.clone(), bridge);
79 let rest = super::rest::router(handler.clone());
80
81 let mcp_service = StreamableHttpService::new(
82 move || Ok(handler.clone()),
83 Arc::new(LocalSessionManager::default()),
84 StreamableHttpServerConfig::default(),
85 );
86
87 let auth_state = Arc::new(crate::auth::AuthState {
88 token: auth_token.clone(),
89 });
90 let info_state = state.clone();
91 let info_auth = auth_token.is_some();
92
93 let privacy_enabled = !state.privacy.disabled_tools.is_empty()
94 || state.privacy.command_allowlist.is_some()
95 || !state.privacy.command_blocklist.is_empty()
96 || state.privacy.redaction_enabled;
97
98 let mut router = axum::Router::new()
99 .route_service("/mcp", mcp_service)
100 .nest("/api/tools", rest)
101 .route(
102 "/info",
103 axum::routing::get(move || {
104 let s = info_state.clone();
105 let app_id = app_identifier.clone();
106 let app_name = app_product_name.clone();
107 async move {
108 axum::Json(serde_json::json!({
109 "name": "victauri",
110 "description": "Full-stack Tauri app inspection: webview + IPC + Rust backend + SQLite",
111 "version": env!("CARGO_PKG_VERSION"),
112 "protocol": "mcp",
113 "app_identifier": app_id,
115 "app_product_name": app_name,
116 "capabilities": ["webview", "ipc", "backend", "database", "filesystem"],
117 "commands_registered": s.registry.count(),
118 "events_captured": s.event_log.len(),
119 "port": s.port.load(Ordering::Relaxed),
120 "auth_required": info_auth,
121 "privacy_mode": privacy_enabled,
122 }))
123 }
124 }),
125 );
126
127 if auth_token.is_some() {
128 router = router.layer(axum::middleware::from_fn_with_state(
129 auth_state,
130 crate::auth::require_auth,
131 ));
132 }
133
134 let limiter = rate_limiter.unwrap_or_else(crate::auth::default_rate_limiter);
135 router = router.layer(axum::middleware::from_fn_with_state(
136 limiter,
137 crate::auth::rate_limit,
138 ));
139
140 router
141 .route(
142 "/health",
143 axum::routing::get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }),
144 )
145 .layer(DefaultBodyLimit::max(2 * 1024 * 1024))
146 .layer(ConcurrencyLimitLayer::new(64))
147 .layer(axum::middleware::from_fn(crate::auth::security_headers))
148 .layer(axum::middleware::from_fn(crate::auth::origin_guard))
149 .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
150}
151
152#[doc(hidden)]
153#[allow(dead_code)]
154pub mod tests_support {
155 #[must_use]
157 pub fn get_memory_stats() -> serde_json::Value {
158 crate::memory::current_stats()
159 }
160}
161
162const PORT_FALLBACK_RANGE: u16 = 10;
163
164pub async fn start_server<R: Runtime>(
171 app_handle: tauri::AppHandle<R>,
172 state: Arc<VictauriState>,
173 port: u16,
174 shutdown_rx: tokio::sync::watch::Receiver<bool>,
175) -> anyhow::Result<()> {
176 start_server_with_options(app_handle, state, port, None, shutdown_rx).await
177}
178
179pub async fn start_server_with_options<R: Runtime>(
186 app_handle: tauri::AppHandle<R>,
187 state: Arc<VictauriState>,
188 port: u16,
189 auth_token: Option<String>,
190 mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
191) -> anyhow::Result<()> {
192 let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
193 let auth_token = normalize_auth_token(auth_token);
196 let token_for_file = auth_token.clone();
197 let app = build_app_with_options(state.clone(), bridge.clone(), auth_token);
198
199 let (listener, actual_port) = try_bind(port).await?;
200
201 if actual_port != port {
202 tracing::warn!("Victauri: port {port} in use, fell back to {actual_port}");
203 }
204
205 state.port.store(actual_port, Ordering::Relaxed);
206 let cfg = bridge.tauri_config();
207 let app_identifier = cfg.get("identifier").and_then(|v| v.as_str());
208 let app_product_name = cfg.get("product_name").and_then(|v| v.as_str());
209 write_port_file(actual_port, app_identifier, app_product_name);
210 let discovery_token = token_for_file
217 .as_deref()
218 .map_or_else(crate::auth::generate_token, String::from);
219 write_token_file(&discovery_token);
220
221 tracing::info!("Victauri MCP server listening on 127.0.0.1:{actual_port}");
222
223 let drain_state = state.clone();
224 let drain_bridge = bridge;
225 let drain_shutdown = state.shutdown_tx.subscribe();
226 let drain_finished = state.task_tracker.track("event_drain_loop");
227 tokio::spawn(async move {
228 event_drain_loop(drain_state, drain_bridge, drain_shutdown).await;
229 drain_finished.store(true, std::sync::atomic::Ordering::Relaxed);
230 });
231
232 let mut shutdown_rx2 = shutdown_rx.clone();
233 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
234 let _ = shutdown_rx.wait_for(|&v| v).await;
235 remove_port_file();
236 tracing::info!("Victauri MCP server shutting down gracefully");
237 });
238
239 tokio::select! {
240 result = server => {
241 if let Err(e) = result {
242 tracing::error!("Victauri MCP server error: {e}");
243 }
244 }
245 _ = async {
246 let _ = shutdown_rx2.wait_for(|&v| v).await;
247 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
248 } => {
249 tracing::warn!("Victauri MCP server shutdown timeout — forcing exit");
250 }
251 }
252 Ok(())
253}
254
255async fn try_bind(preferred: u16) -> anyhow::Result<(tokio::net::TcpListener, u16)> {
256 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{preferred}")).await {
257 return Ok((listener, preferred));
258 }
259
260 for offset in 1..=PORT_FALLBACK_RANGE {
261 let Some(port) = preferred.checked_add(offset) else {
264 break;
265 };
266 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await {
267 return Ok((listener, port));
268 }
269 }
270
271 anyhow::bail!(
272 "could not bind to any port in range {preferred}-{}",
273 preferred.saturating_add(PORT_FALLBACK_RANGE)
274 )
275}
276
277fn discovery_dir() -> std::path::PathBuf {
278 std::env::temp_dir()
279 .join("victauri")
280 .join(std::process::id().to_string())
281}
282
283#[cfg(windows)]
285fn restrict_to_current_user(path: &std::path::Path) {
286 let Ok(username) = std::env::var("USERNAME") else {
287 return;
288 };
289 let path_str = path.to_string_lossy();
290 let _ = std::process::Command::new("icacls")
291 .args([
292 &*path_str,
293 "/inheritance:r",
294 "/grant:r",
295 &format!("{username}:F"),
296 "/q",
297 ])
298 .stdin(std::process::Stdio::null())
299 .stdout(std::process::Stdio::null())
300 .stderr(std::process::Stdio::null())
301 .status();
302}
303
304fn ensure_private_dir(dir: &std::path::Path) {
308 if let Ok(meta) = std::fs::symlink_metadata(dir)
314 && (meta.file_type().is_symlink() || meta.is_file())
315 {
316 let _ = std::fs::remove_file(dir);
317 }
318 let _ = std::fs::create_dir_all(dir);
319 #[cfg(unix)]
320 {
321 use std::os::unix::fs::PermissionsExt;
322 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
323 }
324 #[cfg(windows)]
325 restrict_to_current_user(dir);
326}
327
328fn write_private_file(path: &std::path::Path, contents: &str) {
333 if std::fs::symlink_metadata(path).is_ok() {
337 let _ = std::fs::remove_file(path);
338 }
339 #[cfg(unix)]
340 let result = {
341 use std::io::Write;
342 use std::os::unix::fs::OpenOptionsExt;
343 std::fs::OpenOptions::new()
344 .write(true)
345 .create_new(true)
346 .mode(0o600)
347 .open(path)
348 .and_then(|mut f| f.write_all(contents.as_bytes()))
349 };
350 #[cfg(not(unix))]
351 let result = std::fs::write(path, contents);
352 if let Err(e) = result {
353 tracing::debug!("could not write discovery file {}: {e}", path.display());
354 }
355 #[cfg(windows)]
356 restrict_to_current_user(path);
357}
358
359fn write_port_file(port: u16, identifier: Option<&str>, product_name: Option<&str>) {
360 let dir = discovery_dir();
361 ensure_private_dir(&dir);
362 write_private_file(&dir.join("port"), &port.to_string());
363 let metadata = serde_json::json!({
368 "pid": std::process::id(),
369 "port": port,
370 "identifier": identifier,
371 "product_name": product_name,
372 "started_at": chrono::Utc::now().to_rfc3339(),
373 "version": env!("CARGO_PKG_VERSION"),
374 });
375 write_private_file(&dir.join("metadata.json"), &metadata.to_string());
376}
377
378fn write_token_file(token: &str) {
379 let dir = discovery_dir();
380 ensure_private_dir(&dir);
381 write_private_file(&dir.join("token"), token);
382}
383
384fn remove_port_file() {
385 let _ = std::fs::remove_dir_all(discovery_dir());
386}
387
388#[must_use]
392pub fn parse_bridge_event(ev: &serde_json::Value) -> Option<victauri_core::AppEvent> {
393 use chrono::Utc;
394 use victauri_core::AppEvent;
395
396 let event_type = ev.get("type").and_then(|t| t.as_str()).unwrap_or("");
397 let now = Utc::now();
398
399 let app_event = match event_type {
400 "console" => AppEvent::Console {
401 level: ev
402 .get("level")
403 .and_then(|l| l.as_str())
404 .unwrap_or("log")
405 .to_string(),
406 message: ev
407 .get("message")
408 .and_then(|m| m.as_str())
409 .unwrap_or("")
410 .to_string(),
411 timestamp: now,
412 },
413 "dom_mutation" => AppEvent::DomMutation {
414 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
415 timestamp: now,
416 mutation_count: ev
417 .get("count")
418 .and_then(serde_json::Value::as_u64)
419 .unwrap_or(0) as u32,
420 },
421 "ipc" => {
422 let cmd = ev
423 .get("command")
424 .and_then(|c| c.as_str())
425 .unwrap_or("unknown");
426 AppEvent::Ipc(victauri_core::IpcCall {
427 id: uuid::Uuid::new_v4().to_string(),
428 command: cmd.to_string(),
429 timestamp: now,
430 result: match ev.get("status").and_then(|s| s.as_str()) {
431 Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null),
432 Some("error") => victauri_core::IpcResult::Err("error".to_string()),
433 _ => victauri_core::IpcResult::Pending,
434 },
435 duration_ms: ev
436 .get("duration_ms")
437 .and_then(serde_json::Value::as_f64)
438 .map(|d| d as u64),
439 arg_size_bytes: 0,
440 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
441 })
442 }
443 "network" => AppEvent::StateChange {
444 key: format!(
445 "network.{}",
446 ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET")
447 ),
448 timestamp: now,
449 caused_by: ev
450 .get("url")
451 .and_then(|u| u.as_str())
452 .map(std::string::ToString::to_string),
453 },
454 "navigation" => AppEvent::WindowEvent {
455 label: DEFAULT_WEBVIEW_LABEL.to_string(),
456 event: format!(
457 "navigation.{}",
458 ev.get("nav_type")
459 .and_then(|n| n.as_str())
460 .unwrap_or("unknown")
461 ),
462 timestamp: now,
463 },
464 "dom_interaction" => {
465 let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click");
466 let action = match action_str {
467 "click" => victauri_core::InteractionKind::Click,
468 "double_click" => victauri_core::InteractionKind::DoubleClick,
469 "fill" => victauri_core::InteractionKind::Fill,
470 "key_press" => victauri_core::InteractionKind::KeyPress,
471 "select" => victauri_core::InteractionKind::Select,
472 "navigate" => victauri_core::InteractionKind::Navigate,
473 "scroll" => victauri_core::InteractionKind::Scroll,
474 _ => victauri_core::InteractionKind::Click,
475 };
476 AppEvent::DomInteraction {
477 action,
478 selector: ev
479 .get("selector")
480 .and_then(|s| s.as_str())
481 .unwrap_or("body")
482 .to_string(),
483 value: ev
484 .get("value")
485 .and_then(|v| v.as_str())
486 .map(std::string::ToString::to_string),
487 timestamp: now,
488 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
489 }
490 }
491 _ => return None,
492 };
493
494 Some(app_event)
495}
496
497async fn event_drain_loop(
498 state: Arc<VictauriState>,
499 bridge: Arc<dyn WebviewBridge>,
500 mut shutdown: tokio::sync::watch::Receiver<bool>,
501) {
502 let mut last_drain_ts: f64 = 0.0;
503
504 loop {
505 tokio::select! {
506 _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
507 _ = shutdown.changed() => break,
508 }
509
510 let code = format!("return window.__VICTAURI__?.getEventStream({last_drain_ts})");
511 let id = uuid::Uuid::new_v4().to_string();
512 let (tx, rx) = tokio::sync::oneshot::channel();
513
514 {
515 let mut pending = state.pending_evals.lock().await;
516 if pending.len() >= MAX_PENDING_EVALS {
517 continue;
518 }
519 pending.insert(id.clone(), tx);
520 }
521
522 let id_js = super::helpers::js_string(&id);
523 let inject = format!(
524 r"
525 (async () => {{
526 try {{
527 const __result = await (async () => {{ {code} }})();
528 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
529 id: {id_js},
530 result: JSON.stringify(__result)
531 }});
532 }} catch (e) {{
533 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
534 id: {id_js},
535 result: JSON.stringify({{ __error: e.message }})
536 }});
537 }}
538 }})();
539 "
540 );
541
542 if bridge.eval_webview(None, &inject).is_err() {
543 state.pending_evals.lock().await.remove(&id);
544 continue;
545 }
546
547 let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await
548 else {
549 state.pending_evals.lock().await.remove(&id);
550 continue;
551 };
552
553 let events: Vec<serde_json::Value> = match serde_json::from_str(&result) {
554 Ok(v) => v,
555 Err(_) => continue,
556 };
557
558 for ev in &events {
559 let ts = ev
560 .get("timestamp")
561 .and_then(serde_json::Value::as_f64)
562 .unwrap_or(0.0);
563 if ts > last_drain_ts {
564 last_drain_ts = ts;
565 }
566
567 if let Some(app_event) = parse_bridge_event(ev) {
568 state.event_log.push(app_event.clone());
569 if state.recorder.is_recording() {
570 state.recorder.record_event(app_event);
571 }
572 }
573 }
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use victauri_core::{AppEvent, InteractionKind, IpcResult};
581
582 #[test]
583 fn normalize_auth_token_collapses_empty() {
584 assert_eq!(normalize_auth_token(Some(String::new())), None);
587 assert_eq!(normalize_auth_token(Some(" ".to_string())), None);
588 assert_eq!(normalize_auth_token(Some("\t\n".to_string())), None);
589 assert_eq!(
591 normalize_auth_token(Some("secret-123".to_string())).as_deref(),
592 Some("secret-123")
593 );
594 assert_eq!(normalize_auth_token(None), None);
595 }
596
597 #[tokio::test]
598 async fn try_bind_preferred_port_available() {
599 let (listener, port) = try_bind(0).await.unwrap();
600 let addr = listener.local_addr().unwrap();
601 assert_eq!(port, 0);
602 assert_ne!(addr.port(), 0); }
604
605 #[tokio::test]
606 async fn try_bind_falls_back_when_taken() {
607 let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
608 let blocked_port = blocker.local_addr().unwrap().port();
609
610 let (_, actual) = try_bind(blocked_port).await.unwrap();
611 assert_ne!(actual, blocked_port);
612 assert!(actual > blocked_port);
613 assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
614 }
615
616 #[test]
617 fn port_file_roundtrip() {
618 write_port_file(7777, Some("com.example.app"), Some("Example"));
619 let dir = discovery_dir();
620 let content = std::fs::read_to_string(dir.join("port")).unwrap();
621 assert_eq!(content, "7777");
622 let meta: serde_json::Value =
624 serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
625 .unwrap();
626 assert_eq!(meta["port"], 7777);
627 assert_eq!(meta["pid"], std::process::id());
628 assert_eq!(meta["identifier"], "com.example.app");
630 assert_eq!(meta["product_name"], "Example");
631 remove_port_file();
632 assert!(!dir.exists());
633 }
634
635 #[test]
638 fn parse_dom_interaction_click() {
639 let ev = serde_json::json!({
640 "type": "dom_interaction",
641 "action": "click",
642 "selector": "#submit-btn",
643 });
644 let result = parse_bridge_event(&ev).expect("should produce an event");
645 match result {
646 AppEvent::DomInteraction {
647 action,
648 selector,
649 value,
650 webview_label,
651 ..
652 } => {
653 assert_eq!(action, InteractionKind::Click);
654 assert_eq!(selector, "#submit-btn");
655 assert!(value.is_none());
656 assert_eq!(webview_label, "main");
657 }
658 other => panic!("expected DomInteraction, got {other:?}"),
659 }
660 }
661
662 #[test]
663 fn parse_dom_interaction_fill_with_value() {
664 let ev = serde_json::json!({
665 "type": "dom_interaction",
666 "action": "fill",
667 "selector": "input[name=email]",
668 "value": "test@example.com",
669 });
670 let result = parse_bridge_event(&ev).expect("should produce an event");
671 match result {
672 AppEvent::DomInteraction {
673 action,
674 selector,
675 value,
676 ..
677 } => {
678 assert_eq!(action, InteractionKind::Fill);
679 assert_eq!(selector, "input[name=email]");
680 assert_eq!(value.as_deref(), Some("test@example.com"));
681 }
682 other => panic!("expected DomInteraction, got {other:?}"),
683 }
684 }
685
686 #[test]
687 fn parse_dom_interaction_key_press() {
688 let ev = serde_json::json!({
689 "type": "dom_interaction",
690 "action": "key_press",
691 "selector": "body",
692 "value": "Enter",
693 });
694 let result = parse_bridge_event(&ev).expect("should produce an event");
695 match result {
696 AppEvent::DomInteraction { action, value, .. } => {
697 assert_eq!(action, InteractionKind::KeyPress);
698 assert_eq!(value.as_deref(), Some("Enter"));
699 }
700 other => panic!("expected DomInteraction, got {other:?}"),
701 }
702 }
703
704 #[test]
705 fn parse_dom_interaction_unknown_action_defaults_to_click() {
706 let ev = serde_json::json!({
707 "type": "dom_interaction",
708 "action": "swipe_left",
709 "selector": ".card",
710 });
711 let result = parse_bridge_event(&ev).expect("should produce an event");
712 match result {
713 AppEvent::DomInteraction { action, .. } => {
714 assert_eq!(action, InteractionKind::Click);
715 }
716 other => panic!("expected DomInteraction, got {other:?}"),
717 }
718 }
719
720 #[test]
721 fn parse_dom_interaction_missing_action_defaults_to_click() {
722 let ev = serde_json::json!({
723 "type": "dom_interaction",
724 "selector": "button",
725 });
726 let result = parse_bridge_event(&ev).expect("should produce an event");
727 match result {
728 AppEvent::DomInteraction { action, .. } => {
729 assert_eq!(action, InteractionKind::Click);
730 }
731 other => panic!("expected DomInteraction, got {other:?}"),
732 }
733 }
734
735 #[test]
736 fn parse_dom_interaction_missing_selector_defaults_to_body() {
737 let ev = serde_json::json!({
738 "type": "dom_interaction",
739 "action": "scroll",
740 });
741 let result = parse_bridge_event(&ev).expect("should produce an event");
742 match result {
743 AppEvent::DomInteraction {
744 action, selector, ..
745 } => {
746 assert_eq!(action, InteractionKind::Scroll);
747 assert_eq!(selector, "body");
748 }
749 other => panic!("expected DomInteraction, got {other:?}"),
750 }
751 }
752
753 #[test]
754 fn parse_dom_interaction_all_action_kinds() {
755 let cases = [
756 ("click", InteractionKind::Click),
757 ("double_click", InteractionKind::DoubleClick),
758 ("fill", InteractionKind::Fill),
759 ("key_press", InteractionKind::KeyPress),
760 ("select", InteractionKind::Select),
761 ("navigate", InteractionKind::Navigate),
762 ("scroll", InteractionKind::Scroll),
763 ];
764 for (action_str, expected_kind) in cases {
765 let ev = serde_json::json!({
766 "type": "dom_interaction",
767 "action": action_str,
768 "selector": "body",
769 });
770 let result = parse_bridge_event(&ev)
771 .unwrap_or_else(|| panic!("should produce event for action {action_str}"));
772 match result {
773 AppEvent::DomInteraction { action, .. } => {
774 assert_eq!(action, expected_kind, "mismatch for action {action_str}");
775 }
776 other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
777 }
778 }
779 }
780
781 #[test]
784 fn parse_ipc_status_ok() {
785 let ev = serde_json::json!({
786 "type": "ipc",
787 "command": "greet",
788 "status": "ok",
789 "duration_ms": 42.0,
790 });
791 let result = parse_bridge_event(&ev).expect("should produce an event");
792 match result {
793 AppEvent::Ipc(call) => {
794 assert_eq!(call.command, "greet");
795 assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
796 assert_eq!(call.duration_ms, Some(42));
797 assert_eq!(call.webview_label, "main");
798 }
799 other => panic!("expected Ipc, got {other:?}"),
800 }
801 }
802
803 #[test]
804 fn parse_ipc_status_error() {
805 let ev = serde_json::json!({
806 "type": "ipc",
807 "command": "save_file",
808 "status": "error",
809 });
810 let result = parse_bridge_event(&ev).expect("should produce an event");
811 match result {
812 AppEvent::Ipc(call) => {
813 assert_eq!(call.command, "save_file");
814 assert_eq!(call.result, IpcResult::Err("error".to_string()));
815 }
816 other => panic!("expected Ipc, got {other:?}"),
817 }
818 }
819
820 #[test]
821 fn parse_ipc_status_pending() {
822 let ev = serde_json::json!({
823 "type": "ipc",
824 "command": "long_task",
825 });
826 let result = parse_bridge_event(&ev).expect("should produce an event");
827 match result {
828 AppEvent::Ipc(call) => {
829 assert_eq!(call.result, IpcResult::Pending);
830 assert!(call.duration_ms.is_none());
831 }
832 other => panic!("expected Ipc, got {other:?}"),
833 }
834 }
835
836 #[test]
839 fn parse_console_event() {
840 let ev = serde_json::json!({
841 "type": "console",
842 "level": "warn",
843 "message": "deprecated API usage",
844 });
845 let result = parse_bridge_event(&ev).expect("should produce an event");
846 match result {
847 AppEvent::Console { level, message, .. } => {
848 assert_eq!(level, "warn");
849 assert_eq!(message, "deprecated API usage");
850 }
851 other => panic!("expected Console, got {other:?}"),
852 }
853 }
854
855 #[test]
856 fn parse_console_default_level() {
857 let ev = serde_json::json!({
858 "type": "console",
859 "message": "hello",
860 });
861 let result = parse_bridge_event(&ev).expect("should produce an event");
862 match result {
863 AppEvent::Console { level, message, .. } => {
864 assert_eq!(level, "log");
865 assert_eq!(message, "hello");
866 }
867 other => panic!("expected Console, got {other:?}"),
868 }
869 }
870
871 #[test]
874 fn parse_navigation_event() {
875 let ev = serde_json::json!({
876 "type": "navigation",
877 "nav_type": "push",
878 });
879 let result = parse_bridge_event(&ev).expect("should produce an event");
880 match result {
881 AppEvent::WindowEvent { label, event, .. } => {
882 assert_eq!(label, "main");
883 assert_eq!(event, "navigation.push");
884 }
885 other => panic!("expected WindowEvent, got {other:?}"),
886 }
887 }
888
889 #[test]
890 fn parse_navigation_default_nav_type() {
891 let ev = serde_json::json!({ "type": "navigation" });
892 let result = parse_bridge_event(&ev).expect("should produce an event");
893 match result {
894 AppEvent::WindowEvent { event, .. } => {
895 assert_eq!(event, "navigation.unknown");
896 }
897 other => panic!("expected WindowEvent, got {other:?}"),
898 }
899 }
900
901 #[test]
904 fn parse_dom_mutation_event() {
905 let ev = serde_json::json!({
906 "type": "dom_mutation",
907 "count": 15,
908 });
909 let result = parse_bridge_event(&ev).expect("should produce an event");
910 match result {
911 AppEvent::DomMutation {
912 webview_label,
913 mutation_count,
914 ..
915 } => {
916 assert_eq!(webview_label, "main");
917 assert_eq!(mutation_count, 15);
918 }
919 other => panic!("expected DomMutation, got {other:?}"),
920 }
921 }
922
923 #[test]
926 fn parse_network_event() {
927 let ev = serde_json::json!({
928 "type": "network",
929 "method": "POST",
930 "url": "https://api.example.com/data",
931 });
932 let result = parse_bridge_event(&ev).expect("should produce an event");
933 match result {
934 AppEvent::StateChange { key, caused_by, .. } => {
935 assert_eq!(key, "network.POST");
936 assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
937 }
938 other => panic!("expected StateChange, got {other:?}"),
939 }
940 }
941
942 #[test]
945 fn parse_unknown_type_returns_none() {
946 let ev = serde_json::json!({
947 "type": "custom_telemetry",
948 "payload": 42,
949 });
950 assert!(parse_bridge_event(&ev).is_none());
951 }
952
953 #[test]
954 fn parse_missing_type_field_returns_none() {
955 let ev = serde_json::json!({ "data": "no type here" });
956 assert!(parse_bridge_event(&ev).is_none());
957 }
958
959 #[test]
960 fn parse_empty_object_returns_none() {
961 let ev = serde_json::json!({});
962 assert!(parse_bridge_event(&ev).is_none());
963 }
964}