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