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