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
46async fn backfill_stateless_session_id(
58 req: axum::extract::Request,
59 next: axum::middleware::Next,
60) -> axum::response::Response {
61 let mut resp = next.run(req).await;
62 resp.headers_mut()
63 .entry(axum::http::HeaderName::from_static("mcp-session-id"))
64 .or_insert(axum::http::HeaderValue::from_static("stateless"));
65 resp
66}
67
68pub fn build_app_with_options(
70 state: Arc<VictauriState>,
71 bridge: Arc<dyn WebviewBridge>,
72 auth_token: Option<String>,
73) -> axum::Router {
74 build_app_full(state, bridge, auth_token, None)
75}
76
77pub fn build_app_full(
83 state: Arc<VictauriState>,
84 bridge: Arc<dyn WebviewBridge>,
85 auth_token: Option<String>,
86 rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
87) -> axum::Router {
88 build_app_full_inner(state, bridge, auth_token, rate_limiter, false)
89}
90
91#[doc(hidden)]
102pub fn build_app_stateful(
103 state: Arc<VictauriState>,
104 bridge: Arc<dyn WebviewBridge>,
105 auth_token: Option<String>,
106) -> axum::Router {
107 build_app_full_inner(state, bridge, auth_token, None, true)
108}
109
110fn build_app_full_inner(
111 state: Arc<VictauriState>,
112 bridge: Arc<dyn WebviewBridge>,
113 auth_token: Option<String>,
114 rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
115 stateful: bool,
116) -> axum::Router {
117 let auth_token = normalize_auth_token(auth_token);
120
121 let tauri_cfg = bridge.tauri_config();
124 let app_identifier = tauri_cfg
125 .get("identifier")
126 .and_then(|v| v.as_str())
127 .map(String::from);
128 let app_product_name = tauri_cfg
129 .get("product_name")
130 .and_then(|v| v.as_str())
131 .map(String::from);
132
133 let handler = VictauriMcpHandler::new(state.clone(), bridge);
134 let rest = super::rest::router(handler.clone());
135
136 let mcp_config = if stateful {
163 StreamableHttpServerConfig::default()
164 } else {
165 StreamableHttpServerConfig::default()
166 .with_legacy_session_mode(false)
167 .with_json_response(true)
168 };
169 let mcp_service = StreamableHttpService::new(
170 move || Ok(handler.clone()),
171 Arc::new(LocalSessionManager::default()),
172 mcp_config,
173 );
174
175 let auth_state = Arc::new(crate::auth::AuthState {
176 token: auth_token.clone(),
177 });
178 let info_state = state.clone();
179 let info_auth = auth_token.is_some();
180
181 let privacy_enabled = !state.privacy.disabled_tools.is_empty()
182 || state.privacy.command_allowlist.is_some()
183 || !state.privacy.command_blocklist.is_empty()
184 || state.privacy.redaction_enabled;
185
186 let mut mcp_router = axum::Router::new().route_service("/mcp", mcp_service);
190 if !stateful {
191 mcp_router = mcp_router.layer(axum::middleware::from_fn(backfill_stateless_session_id));
192 }
193
194 let mut router = mcp_router
195 .nest("/api/tools", rest)
196 .route(
197 "/info",
198 axum::routing::get(move || {
199 let s = info_state.clone();
200 let app_id = app_identifier.clone();
201 let app_name = app_product_name.clone();
202 async move {
203 axum::Json(serde_json::json!({
204 "name": "victauri",
205 "description": "Full-stack Tauri app inspection: webview + IPC + Rust backend + SQLite",
206 "version": env!("CARGO_PKG_VERSION"),
207 "protocol": "mcp",
208 "app_identifier": app_id,
210 "app_product_name": app_name,
211 "capabilities": ["webview", "ipc", "backend", "database", "filesystem"],
212 "commands_registered": s.registry.count(),
213 "events_captured": s.event_log.len(),
214 "port": s.port.load(Ordering::Relaxed),
215 "auth_required": info_auth,
216 "privacy_mode": privacy_enabled,
217 }))
218 }
219 }),
220 );
221
222 if auth_token.is_some() {
223 router = router.layer(axum::middleware::from_fn_with_state(
224 auth_state,
225 crate::auth::require_auth,
226 ));
227 }
228
229 router = router.route(
234 "/health",
235 axum::routing::get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }),
236 );
237
238 let limiter = rate_limiter.unwrap_or_else(crate::auth::default_rate_limiter);
239 router = router.layer(axum::middleware::from_fn_with_state(
240 limiter,
241 crate::auth::rate_limit,
242 ));
243
244 router
245 .layer(DefaultBodyLimit::max(2 * 1024 * 1024))
246 .layer(ConcurrencyLimitLayer::new(64))
247 .layer(axum::middleware::from_fn(crate::auth::security_headers))
248 .layer(axum::middleware::from_fn(crate::auth::origin_guard))
249 .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
250}
251
252#[doc(hidden)]
253#[allow(dead_code)]
254pub mod tests_support {
255 #[must_use]
257 pub fn get_memory_stats() -> serde_json::Value {
258 crate::memory::current_stats()
259 }
260}
261
262const PORT_FALLBACK_RANGE: u16 = 10;
263
264pub async fn start_server<R: Runtime>(
271 app_handle: tauri::AppHandle<R>,
272 state: Arc<VictauriState>,
273 port: u16,
274 shutdown_rx: tokio::sync::watch::Receiver<bool>,
275) -> anyhow::Result<()> {
276 start_server_with_options(app_handle, state, port, None, shutdown_rx).await
277}
278
279pub async fn start_server_with_options<R: Runtime>(
286 app_handle: tauri::AppHandle<R>,
287 state: Arc<VictauriState>,
288 port: u16,
289 auth_token: Option<String>,
290 mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
291) -> anyhow::Result<()> {
292 let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
293 let auth_token = normalize_auth_token(auth_token);
296 let token_for_file = auth_token.clone();
297 let app = build_app_with_options(state.clone(), bridge.clone(), auth_token);
298
299 let (listener, actual_port) = try_bind(port).await?;
300
301 if actual_port != port {
302 tracing::warn!("Victauri: port {port} in use, fell back to {actual_port}");
303 }
304
305 state.port.store(actual_port, Ordering::Relaxed);
306 let cfg = bridge.tauri_config();
307 let app_identifier = cfg.get("identifier").and_then(|v| v.as_str());
308 let app_product_name = cfg.get("product_name").and_then(|v| v.as_str());
309 write_port_file(actual_port, app_identifier, app_product_name);
310 let discovery_token = token_for_file
317 .as_deref()
318 .map_or_else(crate::auth::generate_token, String::from);
319 write_token_file(&discovery_token);
320
321 tracing::info!("Victauri MCP server listening on 127.0.0.1:{actual_port}");
322
323 let drain_state = state.clone();
324 let drain_bridge = bridge;
325 let drain_shutdown = state.shutdown_tx.subscribe();
326 let drain_finished = state.task_tracker.track("event_drain_loop");
327 tokio::spawn(async move {
328 event_drain_loop(drain_state, drain_bridge, drain_shutdown).await;
329 drain_finished.store(true, std::sync::atomic::Ordering::Relaxed);
330 });
331
332 let mut shutdown_rx2 = shutdown_rx.clone();
333 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
334 let _ = shutdown_rx.wait_for(|&v| v).await;
335 remove_port_file();
336 tracing::info!("Victauri MCP server shutting down gracefully");
337 });
338
339 tokio::select! {
340 result = server => {
341 if let Err(e) = result {
342 tracing::error!("Victauri MCP server error: {e}");
343 }
344 }
345 _ = async {
346 let _ = shutdown_rx2.wait_for(|&v| v).await;
347 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
348 } => {
349 tracing::warn!("Victauri MCP server shutdown timeout — forcing exit");
350 }
351 }
352 Ok(())
353}
354
355async fn try_bind(preferred: u16) -> anyhow::Result<(tokio::net::TcpListener, u16)> {
356 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{preferred}")).await {
357 return Ok((listener, preferred));
358 }
359
360 for offset in 1..=PORT_FALLBACK_RANGE {
361 let Some(port) = preferred.checked_add(offset) else {
364 break;
365 };
366 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await {
367 return Ok((listener, port));
368 }
369 }
370
371 anyhow::bail!(
372 "could not bind to any port in range {preferred}-{}",
373 preferred.saturating_add(PORT_FALLBACK_RANGE)
374 )
375}
376
377fn discovery_dir() -> std::path::PathBuf {
378 std::env::temp_dir()
379 .join("victauri")
380 .join(std::process::id().to_string())
381}
382
383#[cfg(unix)]
384fn current_euid() -> Option<u32> {
385 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
386 use std::sync::atomic::{AtomicU64, Ordering};
387
388 static NEXT_PROBE: AtomicU64 = AtomicU64::new(0);
389 for _ in 0..16 {
390 let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed);
391 let probe = std::env::temp_dir().join(format!(
392 ".victauri_plugin_uidprobe_{}_{}",
393 std::process::id(),
394 sequence
395 ));
396 let file = std::fs::OpenOptions::new()
397 .write(true)
398 .create_new(true)
399 .mode(0o600)
400 .open(&probe)
401 .ok();
402 if let Some(file) = file {
403 let uid = file.metadata().ok().map(|m| m.uid());
404 drop(file);
405 let _ = std::fs::remove_file(probe);
406 if uid.is_some() {
407 return uid;
408 }
409 }
410 }
411 None
412}
413
414#[cfg(unix)]
415fn ensure_unix_private_dir(path: &std::path::Path) -> bool {
416 use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
417
418 let Some(euid) = current_euid() else {
419 return false;
420 };
421 match std::fs::symlink_metadata(path) {
422 Ok(meta) => {
423 if !meta.file_type().is_dir() || meta.uid() != euid {
424 return false;
425 }
426 if std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).is_err() {
427 return false;
428 }
429 }
430 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
431 let mut builder = std::fs::DirBuilder::new();
432 builder.mode(0o700);
433 if builder.create(path).is_err() {
434 return false;
435 }
436 }
437 Err(_) => return false,
438 }
439 unix_private_dir_is_trusted(path)
440}
441
442#[cfg(unix)]
443fn unix_private_dir_is_trusted(path: &std::path::Path) -> bool {
444 use std::os::unix::fs::{MetadataExt, PermissionsExt};
445
446 let Some(euid) = current_euid() else {
447 return false;
448 };
449 std::fs::symlink_metadata(path).is_ok_and(|meta| {
450 meta.file_type().is_dir() && meta.uid() == euid && (meta.permissions().mode() & 0o077) == 0
451 })
452}
453
454#[cfg(windows)]
456#[allow(unsafe_code)]
457fn current_windows_username() -> Option<String> {
458 use windows::Win32::System::WindowsProgramming::GetUserNameW;
459 use windows::core::PWSTR;
460
461 let mut buffer = [0_u16; 257];
462 let mut len = buffer.len() as u32;
463 unsafe {
466 GetUserNameW(Some(PWSTR(buffer.as_mut_ptr())), &raw mut len).ok()?;
467 }
468 let end = buffer
469 .iter()
470 .position(|unit| *unit == 0)
471 .unwrap_or(len as usize);
472 String::from_utf16(&buffer[..end])
473 .ok()
474 .filter(|name| !name.is_empty())
475}
476
477#[cfg(windows)]
479fn to_wide(path: &std::path::Path) -> Vec<u16> {
480 use std::os::windows::ffi::OsStrExt;
481 path.as_os_str().encode_wide().chain(Some(0)).collect()
482}
483
484#[cfg(windows)]
490struct OwnedSid(Vec<u8>);
491
492#[cfg(windows)]
493impl OwnedSid {
494 fn as_psid(&self) -> windows::Win32::Security::PSID {
495 windows::Win32::Security::PSID(self.0.as_ptr() as *mut core::ffi::c_void)
496 }
497}
498
499#[cfg(windows)]
506#[allow(unsafe_code)]
507fn token_sid(class: windows::Win32::Security::TOKEN_INFORMATION_CLASS) -> Option<OwnedSid> {
508 use windows::Win32::Foundation::{CloseHandle, HANDLE};
509 use windows::Win32::Security::{GetLengthSid, GetTokenInformation, PSID, TOKEN_QUERY};
510 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
511
512 struct TokenGuard(HANDLE);
513 impl Drop for TokenGuard {
514 fn drop(&mut self) {
515 unsafe {
517 let _ = CloseHandle(self.0);
518 }
519 }
520 }
521
522 let mut token = HANDLE::default();
523 unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? };
526 let _guard = TokenGuard(token);
527
528 let mut len = 0_u32;
529 unsafe {
532 let _ = GetTokenInformation(token, class, None, 0, &raw mut len);
533 }
534 if len == 0 {
535 return None;
536 }
537 let mut buf = vec![0_u8; len as usize];
538 unsafe {
540 GetTokenInformation(
541 token,
542 class,
543 Some(buf.as_mut_ptr().cast::<core::ffi::c_void>()),
544 len,
545 &raw mut len,
546 )
547 .ok()?;
548 }
549 let sid_ptr = unsafe { *buf.as_ptr().cast::<PSID>() };
552 let sid_len = unsafe { GetLengthSid(sid_ptr) };
554 if sid_len == 0 {
555 return None;
556 }
557 let mut sid = vec![0_u8; sid_len as usize];
558 unsafe {
560 core::ptr::copy_nonoverlapping(sid_ptr.0.cast::<u8>(), sid.as_mut_ptr(), sid_len as usize);
561 }
562 Some(OwnedSid(sid))
563}
564
565#[cfg(windows)]
572fn acceptable_owner_sids() -> Vec<OwnedSid> {
573 use windows::Win32::Security::{TokenOwner, TokenUser};
574 [TokenUser, TokenOwner]
575 .into_iter()
576 .filter_map(token_sid)
577 .collect()
578}
579
580#[cfg(windows)]
587#[allow(unsafe_code)]
588fn dir_owned_by_current_user(path: &std::path::Path) -> bool {
589 use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
590 use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
591 use windows::Win32::Security::{
592 EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
593 };
594 use windows::core::PCWSTR;
595
596 let acceptable = acceptable_owner_sids();
597 if acceptable.is_empty() {
598 return false;
599 }
600 let wide = to_wide(path);
601 let mut owner = PSID::default();
602 let mut psd = PSECURITY_DESCRIPTOR::default();
603 let rc = unsafe {
606 GetNamedSecurityInfoW(
607 PCWSTR(wide.as_ptr()),
608 SE_FILE_OBJECT,
609 OWNER_SECURITY_INFORMATION,
610 Some(&raw mut owner),
611 None,
612 None,
613 None,
614 &raw mut psd,
615 )
616 };
617 if rc != ERROR_SUCCESS {
618 return false;
619 }
620 let owned = acceptable
622 .iter()
623 .any(|sid| unsafe { EqualSid(owner, sid.as_psid()).is_ok() });
624 unsafe {
626 let _ = LocalFree(Some(HLOCAL(psd.0)));
627 }
628 owned
629}
630
631#[cfg(windows)]
640#[allow(unsafe_code)]
641fn apply_owner_only_dacl(path: &std::path::Path) -> bool {
642 use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
643 use windows::Win32::Security::Authorization::{
644 EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW,
645 SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
646 };
647 use windows::Win32::Security::{
648 ACE_FLAGS, ACL, DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION,
649 };
650 use windows::core::PWSTR;
651
652 use windows::Win32::Security::TokenUser;
653
654 const GENERIC_ALL_RIGHTS: u32 = 0x1000_0000;
656 const SUB_CONTAINERS_AND_OBJECTS_INHERIT: u32 = 0x3;
657
658 let Some(me) = token_sid(TokenUser) else {
661 return false;
662 };
663
664 let explicit = EXPLICIT_ACCESS_W {
665 grfAccessPermissions: GENERIC_ALL_RIGHTS,
666 grfAccessMode: SET_ACCESS,
667 grfInheritance: ACE_FLAGS(SUB_CONTAINERS_AND_OBJECTS_INHERIT),
668 Trustee: TRUSTEE_W {
669 pMultipleTrustee: core::ptr::null_mut(),
670 MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE,
671 TrusteeForm: TRUSTEE_IS_SID,
672 TrusteeType: TRUSTEE_IS_USER,
673 ptstrName: PWSTR(me.as_psid().0.cast::<u16>()),
674 },
675 };
676
677 let mut new_acl: *mut ACL = core::ptr::null_mut();
678 let rc = unsafe { SetEntriesInAclW(Some(&[explicit]), None, &raw mut new_acl) };
681 if rc != ERROR_SUCCESS || new_acl.is_null() {
682 return false;
683 }
684
685 let mut wide = to_wide(path);
686 let set_rc = unsafe {
689 SetNamedSecurityInfoW(
690 PWSTR(wide.as_mut_ptr()),
691 SE_FILE_OBJECT,
692 DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
693 None,
694 None,
695 Some(new_acl),
696 None,
697 )
698 };
699 unsafe {
701 let _ = LocalFree(Some(HLOCAL(new_acl.cast::<core::ffi::c_void>())));
702 }
703 set_rc == ERROR_SUCCESS
704}
705
706#[cfg(windows)]
711fn icacls_restrict_to_current_user(path: &std::path::Path) -> bool {
712 let Some(username) = current_windows_username() else {
713 return false;
714 };
715 let path_str = path.to_string_lossy();
716 std::process::Command::new("icacls")
717 .args([
718 &*path_str,
719 "/inheritance:r",
720 "/remove",
721 "*S-1-1-0",
722 "*S-1-5-32-545",
723 "*S-1-5-11",
724 "/grant:r",
725 &format!("{username}:F"),
726 "/q",
727 ])
728 .stdin(std::process::Stdio::null())
729 .stdout(std::process::Stdio::null())
730 .stderr(std::process::Stdio::null())
731 .status()
732 .is_ok_and(|status| status.success())
733}
734
735#[cfg(windows)]
739fn restrict_to_current_user(path: &std::path::Path) -> bool {
740 if apply_owner_only_dacl(path) {
741 return true;
742 }
743 tracing::warn!(
744 "owner-only DACL apply failed for {}; falling back to icacls",
745 path.display()
746 );
747 icacls_restrict_to_current_user(path)
748}
749
750#[cfg(windows)]
751fn ensure_windows_private_dir(path: &std::path::Path, remove_on_acl_failure: bool) -> bool {
752 let mut created = false;
753 match std::fs::symlink_metadata(path) {
754 Ok(meta) => {
755 if !meta.file_type().is_dir() {
756 return false;
757 }
758 }
759 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
760 if std::fs::create_dir(path).is_err() {
761 return false;
762 }
763 created = true;
764 }
765 Err(_) => return false,
766 }
767
768 if !dir_owned_by_current_user(path) {
769 tracing::warn!(
770 "refusing discovery directory not owned by current user: {}",
771 path.display()
772 );
773 if created || remove_on_acl_failure {
774 let _ = std::fs::remove_dir_all(path);
775 }
776 return false;
777 }
778
779 if !restrict_to_current_user(path) {
780 if created || remove_on_acl_failure {
781 let _ = std::fs::remove_dir_all(path);
782 }
783 return false;
784 }
785
786 true
787}
788
789fn ensure_private_dir(dir: &std::path::Path) -> bool {
792 #[cfg(unix)]
793 {
794 let Some(root) = dir.parent() else {
795 return false;
796 };
797 if !ensure_unix_private_dir(root) || !ensure_unix_private_dir(dir) {
798 tracing::warn!("refusing untrusted discovery path {}", dir.display());
799 return false;
800 }
801 }
802 #[cfg(windows)]
803 {
804 let Some(root) = dir.parent() else {
805 return false;
806 };
807 if !ensure_windows_private_dir(root, false) {
810 tracing::warn!("refusing untrusted discovery root {}", root.display());
811 return false;
812 }
813 if !ensure_windows_private_dir(dir, true) {
818 tracing::warn!("refusing untrusted discovery path {}", dir.display());
819 return false;
820 }
821 }
822 #[cfg(all(not(unix), not(windows)))]
823 if std::fs::create_dir_all(dir).is_err() {
824 return false;
825 }
826 true
827}
828
829fn write_private_file(path: &std::path::Path, contents: &str) {
834 if std::fs::symlink_metadata(path).is_ok() {
838 let _ = std::fs::remove_file(path);
839 }
840 #[cfg(unix)]
841 let result = {
842 use std::io::Write;
843 use std::os::unix::fs::OpenOptionsExt;
844 std::fs::OpenOptions::new()
845 .write(true)
846 .create_new(true)
847 .mode(0o600)
848 .open(path)
849 .and_then(|mut f| f.write_all(contents.as_bytes()))
850 };
851 #[cfg(not(unix))]
852 let result = {
853 use std::io::Write;
854 std::fs::OpenOptions::new()
855 .write(true)
856 .create_new(true)
857 .open(path)
858 .and_then(|mut f| f.write_all(contents.as_bytes()))
859 };
860 #[cfg(windows)]
865 match result {
866 Ok(()) => {
867 if !restrict_to_current_user(path) {
868 let _ = std::fs::remove_file(path);
869 tracing::warn!("could not restrict discovery file {}", path.display());
870 }
871 }
872 Err(e) => {
873 tracing::debug!("could not write discovery file {}: {e}", path.display());
874 }
875 }
876 #[cfg(not(windows))]
877 if let Err(e) = result {
878 tracing::debug!("could not write discovery file {}: {e}", path.display());
879 }
880}
881
882fn write_port_file(port: u16, identifier: Option<&str>, product_name: Option<&str>) {
883 let dir = discovery_dir();
884 if !ensure_private_dir(&dir) {
885 return;
886 }
887 write_private_file(&dir.join("port"), &port.to_string());
888 let metadata = serde_json::json!({
893 "pid": std::process::id(),
894 "port": port,
895 "identifier": identifier,
896 "product_name": product_name,
897 "started_at": chrono::Utc::now().to_rfc3339(),
898 "version": env!("CARGO_PKG_VERSION"),
899 });
900 write_private_file(&dir.join("metadata.json"), &metadata.to_string());
901}
902
903fn write_token_file(token: &str) {
904 let dir = discovery_dir();
905 if !ensure_private_dir(&dir) {
906 return;
907 }
908 write_private_file(&dir.join("token"), token);
909}
910
911fn remove_port_file() {
912 let dir = discovery_dir();
913 #[cfg(unix)]
914 {
915 let Some(root) = dir.parent() else {
916 return;
917 };
918 if !unix_private_dir_is_trusted(root) || !unix_private_dir_is_trusted(&dir) {
919 return;
920 }
921 }
922 let _ = std::fs::remove_dir_all(dir);
923}
924
925#[must_use]
929pub fn parse_bridge_event(ev: &serde_json::Value) -> Option<victauri_core::AppEvent> {
930 use chrono::Utc;
931 use victauri_core::AppEvent;
932
933 let event_type = ev.get("type").and_then(|t| t.as_str()).unwrap_or("");
934 let now = Utc::now();
935
936 let app_event = match event_type {
937 "console" => AppEvent::Console {
938 level: ev
939 .get("level")
940 .and_then(|l| l.as_str())
941 .unwrap_or("log")
942 .to_string(),
943 message: ev
944 .get("message")
945 .and_then(|m| m.as_str())
946 .unwrap_or("")
947 .to_string(),
948 timestamp: now,
949 },
950 "dom_mutation" => AppEvent::DomMutation {
951 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
952 timestamp: now,
953 mutation_count: ev
954 .get("count")
955 .and_then(serde_json::Value::as_u64)
956 .unwrap_or(0) as u32,
957 },
958 "ipc" => {
959 let cmd = ev
960 .get("command")
961 .and_then(|c| c.as_str())
962 .unwrap_or("unknown");
963 AppEvent::Ipc(victauri_core::IpcCall {
964 id: uuid::Uuid::new_v4().to_string(),
965 command: cmd.to_string(),
966 timestamp: now,
967 result: match ev.get("status").and_then(|s| s.as_str()) {
968 Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null),
969 Some("error") => victauri_core::IpcResult::Err("error".to_string()),
970 _ => victauri_core::IpcResult::Pending,
971 },
972 duration_ms: ev
973 .get("duration_ms")
974 .and_then(serde_json::Value::as_f64)
975 .map(|d| d as u64),
976 arg_size_bytes: 0,
977 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
978 })
979 }
980 "network" => AppEvent::StateChange {
981 key: format!(
982 "network.{}",
983 ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET")
984 ),
985 timestamp: now,
986 caused_by: ev
987 .get("url")
988 .and_then(|u| u.as_str())
989 .map(std::string::ToString::to_string),
990 },
991 "navigation" => AppEvent::WindowEvent {
992 label: DEFAULT_WEBVIEW_LABEL.to_string(),
993 event: format!(
994 "navigation.{}",
995 ev.get("nav_type")
996 .and_then(|n| n.as_str())
997 .unwrap_or("unknown")
998 ),
999 timestamp: now,
1000 },
1001 "dom_interaction" => {
1002 let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click");
1003 let action = match action_str {
1004 "click" => victauri_core::InteractionKind::Click,
1005 "double_click" => victauri_core::InteractionKind::DoubleClick,
1006 "fill" => victauri_core::InteractionKind::Fill,
1007 "key_press" => victauri_core::InteractionKind::KeyPress,
1008 "select" => victauri_core::InteractionKind::Select,
1009 "navigate" => victauri_core::InteractionKind::Navigate,
1010 "scroll" => victauri_core::InteractionKind::Scroll,
1011 _ => victauri_core::InteractionKind::Click,
1012 };
1013 AppEvent::DomInteraction {
1014 action,
1015 selector: ev
1016 .get("selector")
1017 .and_then(|s| s.as_str())
1018 .unwrap_or("body")
1019 .to_string(),
1020 value: ev
1021 .get("value")
1022 .and_then(|v| v.as_str())
1023 .map(std::string::ToString::to_string),
1024 timestamp: now,
1025 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
1026 }
1027 }
1028 _ => return None,
1029 };
1030
1031 Some(app_event)
1032}
1033
1034async fn event_drain_loop(
1035 state: Arc<VictauriState>,
1036 bridge: Arc<dyn WebviewBridge>,
1037 mut shutdown: tokio::sync::watch::Receiver<bool>,
1038) {
1039 let mut watermarks: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
1045
1046 loop {
1047 tokio::select! {
1048 _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1049 _ = shutdown.changed() => break,
1050 }
1051
1052 if !state.recorder.is_recording() {
1063 continue;
1064 }
1065
1066 let labels = bridge.list_window_labels();
1067 if labels.is_empty() {
1068 continue;
1069 }
1070 watermarks.retain(|label, _| labels.contains(label));
1073
1074 let mut set = tokio::task::JoinSet::new();
1079 for label in &labels {
1080 let since = watermarks.get(label).copied().unwrap_or(0.0);
1081 let state = Arc::clone(&state);
1082 let bridge = Arc::clone(&bridge);
1083 let label = label.clone();
1084 set.spawn(async move {
1085 let newest = drain_window(&state, &bridge, &label, since).await;
1086 (label, newest)
1087 });
1088 }
1089 while let Some(res) = set.join_next().await {
1090 if let Ok((label, Some(newest))) = res {
1091 watermarks.insert(label, newest);
1092 }
1093 }
1094 }
1095}
1096
1097async fn drain_window(
1103 state: &Arc<VictauriState>,
1104 bridge: &Arc<dyn WebviewBridge>,
1105 label: &str,
1106 since: f64,
1107) -> Option<f64> {
1108 let code = format!("return window.__VICTAURI__?.getEventStream({since})");
1109 let id = uuid::Uuid::new_v4().to_string();
1110 let (tx, rx) = tokio::sync::oneshot::channel();
1111
1112 {
1113 let mut pending = state.pending_evals.lock().await;
1114 if pending.len() >= MAX_PENDING_EVALS {
1115 return None;
1116 }
1117 pending.insert(id.clone(), tx);
1118 }
1119
1120 let id_js = super::helpers::js_string(&id);
1121 let inject = format!(
1122 r"
1123 (async () => {{
1124 try {{
1125 const __result = await (async () => {{ {code} }})();
1126 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1127 id: {id_js},
1128 result: JSON.stringify(__result)
1129 }});
1130 }} catch (e) {{
1131 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1132 id: {id_js},
1133 result: JSON.stringify({{ __error: e.message }})
1134 }});
1135 }}
1136 }})();
1137 "
1138 );
1139
1140 if bridge.eval_webview(Some(label), &inject).is_err() {
1141 state.pending_evals.lock().await.remove(&id);
1142 return None;
1143 }
1144
1145 let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await else {
1146 state.pending_evals.lock().await.remove(&id);
1147 return None;
1148 };
1149
1150 let events: Vec<serde_json::Value> = serde_json::from_str(&result).ok()?;
1151
1152 let mut newest = since;
1153 for ev in &events {
1154 let ts = ev
1155 .get("timestamp")
1156 .and_then(serde_json::Value::as_f64)
1157 .unwrap_or(0.0);
1158 if ts > newest {
1159 newest = ts;
1160 }
1161
1162 if let Some(app_event) = parse_bridge_event(ev) {
1163 state.event_log.push(app_event.clone());
1164 if state.recorder.is_recording() {
1165 state.recorder.record_event(app_event);
1166 }
1167 }
1168 }
1169 Some(newest)
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174 use super::*;
1175 use victauri_core::{AppEvent, InteractionKind, IpcResult};
1176
1177 #[cfg(windows)]
1181 #[test]
1182 fn owner_only_dacl_removes_pre_planted_guests_ace() {
1183 use std::process::Command;
1184 let dir = std::env::temp_dir()
1185 .join("victauri_acl_test")
1186 .join(format!("p{}", std::process::id()));
1187 let _ = std::fs::remove_dir_all(&dir);
1188 std::fs::create_dir_all(&dir).expect("create test dir");
1189
1190 assert!(
1193 dir_owned_by_current_user(&dir),
1194 "a freshly created dir must be recognized as owned by this process"
1195 );
1196
1197 let path_str = dir.to_string_lossy().to_string();
1198
1199 let Ok(grant) = Command::new("icacls")
1201 .args([path_str.as_str(), "/grant", "*S-1-5-32-546:(OI)(CI)F", "/q"])
1202 .output()
1203 else {
1204 let _ = std::fs::remove_dir_all(&dir);
1205 return; };
1207 if !grant.status.success() {
1208 let _ = std::fs::remove_dir_all(&dir);
1209 return; }
1211
1212 let before = Command::new("icacls")
1213 .arg(path_str.as_str())
1214 .output()
1215 .expect("icacls read");
1216 let before_s = String::from_utf8_lossy(&before.stdout);
1217 assert!(
1218 before_s.contains("Guests"),
1219 "pre-condition: the planted Guests ACE should be visible, got:\n{before_s}"
1220 );
1221
1222 assert!(
1224 apply_owner_only_dacl(&dir),
1225 "apply_owner_only_dacl must succeed on a directory we own"
1226 );
1227
1228 let after = Command::new("icacls")
1229 .arg(path_str.as_str())
1230 .output()
1231 .expect("icacls read");
1232 let after_s = String::from_utf8_lossy(&after.stdout);
1233 assert!(
1234 !after_s.contains("Guests"),
1235 "the pre-planted Guests ACE must NOT survive the owner-only DACL, got:\n{after_s}"
1236 );
1237
1238 let _ = std::fs::remove_dir_all(&dir);
1239 }
1240
1241 #[test]
1242 fn normalize_auth_token_collapses_empty() {
1243 assert_eq!(normalize_auth_token(Some(String::new())), None);
1246 assert_eq!(normalize_auth_token(Some(" ".to_string())), None);
1247 assert_eq!(normalize_auth_token(Some("\t\n".to_string())), None);
1248 assert_eq!(
1250 normalize_auth_token(Some("secret-123".to_string())).as_deref(),
1251 Some("secret-123")
1252 );
1253 assert_eq!(normalize_auth_token(None), None);
1254 }
1255
1256 #[tokio::test]
1257 async fn try_bind_preferred_port_available() {
1258 let (listener, port) = try_bind(0).await.unwrap();
1259 let addr = listener.local_addr().unwrap();
1260 assert_eq!(port, 0);
1261 assert_ne!(addr.port(), 0); }
1263
1264 #[tokio::test]
1265 async fn try_bind_falls_back_when_taken() {
1266 let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1267 let blocked_port = blocker.local_addr().unwrap().port();
1268
1269 let (_, actual) = try_bind(blocked_port).await.unwrap();
1270 assert_ne!(actual, blocked_port);
1271 assert!(actual > blocked_port);
1272 assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
1273 }
1274
1275 #[test]
1276 fn port_file_roundtrip() {
1277 write_port_file(7777, Some("com.example.app"), Some("Example"));
1278 let dir = discovery_dir();
1279 let content = std::fs::read_to_string(dir.join("port")).unwrap();
1280 assert_eq!(content, "7777");
1281 let meta: serde_json::Value =
1283 serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
1284 .unwrap();
1285 assert_eq!(meta["port"], 7777);
1286 assert_eq!(meta["pid"], std::process::id());
1287 assert_eq!(meta["identifier"], "com.example.app");
1289 assert_eq!(meta["product_name"], "Example");
1290 remove_port_file();
1291 assert!(!dir.exists());
1292 }
1293
1294 #[cfg(windows)]
1295 #[test]
1296 fn private_dir_restricts_shared_root_and_pid_dir() {
1297 let base = std::env::temp_dir()
1298 .join("victauri_private_root_test")
1299 .join(format!("p{}", std::process::id()));
1300 let dir = base.join("victauri").join("12345");
1301 let _ = std::fs::remove_dir_all(&base);
1302 std::fs::create_dir_all(&base).expect("create parent test dir");
1303
1304 assert!(
1305 ensure_private_dir(&dir),
1306 "a fresh discovery root and pid dir owned by this user should be accepted"
1307 );
1308 assert!(
1309 dir_owned_by_current_user(&base.join("victauri")),
1310 "shared discovery root must be owned by this process user"
1311 );
1312 assert!(
1313 dir_owned_by_current_user(&dir),
1314 "pid discovery dir must be owned by this process user"
1315 );
1316
1317 let _ = std::fs::remove_dir_all(&base);
1318 }
1319
1320 #[cfg(unix)]
1321 #[test]
1322 fn private_dir_refuses_symlink_without_chmodding_target() {
1323 use std::os::unix::fs::PermissionsExt;
1324
1325 let base = tempfile::tempdir().unwrap();
1326 let target = base.path().join("target");
1327 let link = base.path().join("link");
1328 std::fs::create_dir(&target).unwrap();
1329 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1330 std::os::unix::fs::symlink(&target, &link).unwrap();
1331
1332 assert!(!ensure_unix_private_dir(&link));
1333 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1334 assert_eq!(mode, 0o755, "symlink target permissions must be untouched");
1335 }
1336
1337 #[test]
1340 fn parse_dom_interaction_click() {
1341 let ev = serde_json::json!({
1342 "type": "dom_interaction",
1343 "action": "click",
1344 "selector": "#submit-btn",
1345 });
1346 let result = parse_bridge_event(&ev).expect("should produce an event");
1347 match result {
1348 AppEvent::DomInteraction {
1349 action,
1350 selector,
1351 value,
1352 webview_label,
1353 ..
1354 } => {
1355 assert_eq!(action, InteractionKind::Click);
1356 assert_eq!(selector, "#submit-btn");
1357 assert!(value.is_none());
1358 assert_eq!(webview_label, "main");
1359 }
1360 other => panic!("expected DomInteraction, got {other:?}"),
1361 }
1362 }
1363
1364 #[test]
1365 fn parse_dom_interaction_fill_with_value() {
1366 let ev = serde_json::json!({
1367 "type": "dom_interaction",
1368 "action": "fill",
1369 "selector": "input[name=email]",
1370 "value": "test@example.com",
1371 });
1372 let result = parse_bridge_event(&ev).expect("should produce an event");
1373 match result {
1374 AppEvent::DomInteraction {
1375 action,
1376 selector,
1377 value,
1378 ..
1379 } => {
1380 assert_eq!(action, InteractionKind::Fill);
1381 assert_eq!(selector, "input[name=email]");
1382 assert_eq!(value.as_deref(), Some("test@example.com"));
1383 }
1384 other => panic!("expected DomInteraction, got {other:?}"),
1385 }
1386 }
1387
1388 #[test]
1389 fn parse_dom_interaction_key_press() {
1390 let ev = serde_json::json!({
1391 "type": "dom_interaction",
1392 "action": "key_press",
1393 "selector": "body",
1394 "value": "Enter",
1395 });
1396 let result = parse_bridge_event(&ev).expect("should produce an event");
1397 match result {
1398 AppEvent::DomInteraction { action, value, .. } => {
1399 assert_eq!(action, InteractionKind::KeyPress);
1400 assert_eq!(value.as_deref(), Some("Enter"));
1401 }
1402 other => panic!("expected DomInteraction, got {other:?}"),
1403 }
1404 }
1405
1406 #[test]
1407 fn parse_dom_interaction_unknown_action_defaults_to_click() {
1408 let ev = serde_json::json!({
1409 "type": "dom_interaction",
1410 "action": "swipe_left",
1411 "selector": ".card",
1412 });
1413 let result = parse_bridge_event(&ev).expect("should produce an event");
1414 match result {
1415 AppEvent::DomInteraction { action, .. } => {
1416 assert_eq!(action, InteractionKind::Click);
1417 }
1418 other => panic!("expected DomInteraction, got {other:?}"),
1419 }
1420 }
1421
1422 #[test]
1423 fn parse_dom_interaction_missing_action_defaults_to_click() {
1424 let ev = serde_json::json!({
1425 "type": "dom_interaction",
1426 "selector": "button",
1427 });
1428 let result = parse_bridge_event(&ev).expect("should produce an event");
1429 match result {
1430 AppEvent::DomInteraction { action, .. } => {
1431 assert_eq!(action, InteractionKind::Click);
1432 }
1433 other => panic!("expected DomInteraction, got {other:?}"),
1434 }
1435 }
1436
1437 #[test]
1438 fn parse_dom_interaction_missing_selector_defaults_to_body() {
1439 let ev = serde_json::json!({
1440 "type": "dom_interaction",
1441 "action": "scroll",
1442 });
1443 let result = parse_bridge_event(&ev).expect("should produce an event");
1444 match result {
1445 AppEvent::DomInteraction {
1446 action, selector, ..
1447 } => {
1448 assert_eq!(action, InteractionKind::Scroll);
1449 assert_eq!(selector, "body");
1450 }
1451 other => panic!("expected DomInteraction, got {other:?}"),
1452 }
1453 }
1454
1455 #[test]
1456 fn parse_dom_interaction_all_action_kinds() {
1457 let cases = [
1458 ("click", InteractionKind::Click),
1459 ("double_click", InteractionKind::DoubleClick),
1460 ("fill", InteractionKind::Fill),
1461 ("key_press", InteractionKind::KeyPress),
1462 ("select", InteractionKind::Select),
1463 ("navigate", InteractionKind::Navigate),
1464 ("scroll", InteractionKind::Scroll),
1465 ];
1466 for (action_str, expected_kind) in cases {
1467 let ev = serde_json::json!({
1468 "type": "dom_interaction",
1469 "action": action_str,
1470 "selector": "body",
1471 });
1472 let result = parse_bridge_event(&ev)
1473 .unwrap_or_else(|| panic!("should produce event for action {action_str}"));
1474 match result {
1475 AppEvent::DomInteraction { action, .. } => {
1476 assert_eq!(action, expected_kind, "mismatch for action {action_str}");
1477 }
1478 other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
1479 }
1480 }
1481 }
1482
1483 #[test]
1486 fn parse_ipc_status_ok() {
1487 let ev = serde_json::json!({
1488 "type": "ipc",
1489 "command": "greet",
1490 "status": "ok",
1491 "duration_ms": 42.0,
1492 });
1493 let result = parse_bridge_event(&ev).expect("should produce an event");
1494 match result {
1495 AppEvent::Ipc(call) => {
1496 assert_eq!(call.command, "greet");
1497 assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
1498 assert_eq!(call.duration_ms, Some(42));
1499 assert_eq!(call.webview_label, "main");
1500 }
1501 other => panic!("expected Ipc, got {other:?}"),
1502 }
1503 }
1504
1505 #[test]
1506 fn parse_ipc_status_error() {
1507 let ev = serde_json::json!({
1508 "type": "ipc",
1509 "command": "save_file",
1510 "status": "error",
1511 });
1512 let result = parse_bridge_event(&ev).expect("should produce an event");
1513 match result {
1514 AppEvent::Ipc(call) => {
1515 assert_eq!(call.command, "save_file");
1516 assert_eq!(call.result, IpcResult::Err("error".to_string()));
1517 }
1518 other => panic!("expected Ipc, got {other:?}"),
1519 }
1520 }
1521
1522 #[test]
1523 fn parse_ipc_status_pending() {
1524 let ev = serde_json::json!({
1525 "type": "ipc",
1526 "command": "long_task",
1527 });
1528 let result = parse_bridge_event(&ev).expect("should produce an event");
1529 match result {
1530 AppEvent::Ipc(call) => {
1531 assert_eq!(call.result, IpcResult::Pending);
1532 assert!(call.duration_ms.is_none());
1533 }
1534 other => panic!("expected Ipc, got {other:?}"),
1535 }
1536 }
1537
1538 #[test]
1541 fn parse_console_event() {
1542 let ev = serde_json::json!({
1543 "type": "console",
1544 "level": "warn",
1545 "message": "deprecated API usage",
1546 });
1547 let result = parse_bridge_event(&ev).expect("should produce an event");
1548 match result {
1549 AppEvent::Console { level, message, .. } => {
1550 assert_eq!(level, "warn");
1551 assert_eq!(message, "deprecated API usage");
1552 }
1553 other => panic!("expected Console, got {other:?}"),
1554 }
1555 }
1556
1557 #[test]
1558 fn parse_console_default_level() {
1559 let ev = serde_json::json!({
1560 "type": "console",
1561 "message": "hello",
1562 });
1563 let result = parse_bridge_event(&ev).expect("should produce an event");
1564 match result {
1565 AppEvent::Console { level, message, .. } => {
1566 assert_eq!(level, "log");
1567 assert_eq!(message, "hello");
1568 }
1569 other => panic!("expected Console, got {other:?}"),
1570 }
1571 }
1572
1573 #[test]
1576 fn parse_navigation_event() {
1577 let ev = serde_json::json!({
1578 "type": "navigation",
1579 "nav_type": "push",
1580 });
1581 let result = parse_bridge_event(&ev).expect("should produce an event");
1582 match result {
1583 AppEvent::WindowEvent { label, event, .. } => {
1584 assert_eq!(label, "main");
1585 assert_eq!(event, "navigation.push");
1586 }
1587 other => panic!("expected WindowEvent, got {other:?}"),
1588 }
1589 }
1590
1591 #[test]
1592 fn parse_navigation_default_nav_type() {
1593 let ev = serde_json::json!({ "type": "navigation" });
1594 let result = parse_bridge_event(&ev).expect("should produce an event");
1595 match result {
1596 AppEvent::WindowEvent { event, .. } => {
1597 assert_eq!(event, "navigation.unknown");
1598 }
1599 other => panic!("expected WindowEvent, got {other:?}"),
1600 }
1601 }
1602
1603 #[test]
1606 fn parse_dom_mutation_event() {
1607 let ev = serde_json::json!({
1608 "type": "dom_mutation",
1609 "count": 15,
1610 });
1611 let result = parse_bridge_event(&ev).expect("should produce an event");
1612 match result {
1613 AppEvent::DomMutation {
1614 webview_label,
1615 mutation_count,
1616 ..
1617 } => {
1618 assert_eq!(webview_label, "main");
1619 assert_eq!(mutation_count, 15);
1620 }
1621 other => panic!("expected DomMutation, got {other:?}"),
1622 }
1623 }
1624
1625 #[test]
1628 fn parse_network_event() {
1629 let ev = serde_json::json!({
1630 "type": "network",
1631 "method": "POST",
1632 "url": "https://api.example.com/data",
1633 });
1634 let result = parse_bridge_event(&ev).expect("should produce an event");
1635 match result {
1636 AppEvent::StateChange { key, caused_by, .. } => {
1637 assert_eq!(key, "network.POST");
1638 assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
1639 }
1640 other => panic!("expected StateChange, got {other:?}"),
1641 }
1642 }
1643
1644 #[test]
1647 fn parse_unknown_type_returns_none() {
1648 let ev = serde_json::json!({
1649 "type": "custom_telemetry",
1650 "payload": 42,
1651 });
1652 assert!(parse_bridge_event(&ev).is_none());
1653 }
1654
1655 #[test]
1656 fn parse_missing_type_field_returns_none() {
1657 let ev = serde_json::json!({ "data": "no type here" });
1658 assert!(parse_bridge_event(&ev).is_none());
1659 }
1660
1661 #[test]
1662 fn parse_empty_object_returns_none() {
1663 let ev = serde_json::json!({});
1664 assert!(parse_bridge_event(&ev).is_none());
1665 }
1666}