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 let limiter = rate_limiter.unwrap_or_else(crate::auth::default_rate_limiter);
224 router = router.layer(axum::middleware::from_fn_with_state(
225 limiter,
226 crate::auth::rate_limit,
227 ));
228
229 router
230 .route(
231 "/health",
232 axum::routing::get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }),
233 )
234 .layer(DefaultBodyLimit::max(2 * 1024 * 1024))
235 .layer(ConcurrencyLimitLayer::new(64))
236 .layer(axum::middleware::from_fn(crate::auth::security_headers))
237 .layer(axum::middleware::from_fn(crate::auth::origin_guard))
238 .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
239}
240
241#[doc(hidden)]
242#[allow(dead_code)]
243pub mod tests_support {
244 #[must_use]
246 pub fn get_memory_stats() -> serde_json::Value {
247 crate::memory::current_stats()
248 }
249}
250
251const PORT_FALLBACK_RANGE: u16 = 10;
252
253pub async fn start_server<R: Runtime>(
260 app_handle: tauri::AppHandle<R>,
261 state: Arc<VictauriState>,
262 port: u16,
263 shutdown_rx: tokio::sync::watch::Receiver<bool>,
264) -> anyhow::Result<()> {
265 start_server_with_options(app_handle, state, port, None, shutdown_rx).await
266}
267
268pub async fn start_server_with_options<R: Runtime>(
275 app_handle: tauri::AppHandle<R>,
276 state: Arc<VictauriState>,
277 port: u16,
278 auth_token: Option<String>,
279 mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
280) -> anyhow::Result<()> {
281 let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
282 let auth_token = normalize_auth_token(auth_token);
285 let token_for_file = auth_token.clone();
286 let app = build_app_with_options(state.clone(), bridge.clone(), auth_token);
287
288 let (listener, actual_port) = try_bind(port).await?;
289
290 if actual_port != port {
291 tracing::warn!("Victauri: port {port} in use, fell back to {actual_port}");
292 }
293
294 state.port.store(actual_port, Ordering::Relaxed);
295 let cfg = bridge.tauri_config();
296 let app_identifier = cfg.get("identifier").and_then(|v| v.as_str());
297 let app_product_name = cfg.get("product_name").and_then(|v| v.as_str());
298 write_port_file(actual_port, app_identifier, app_product_name);
299 let discovery_token = token_for_file
306 .as_deref()
307 .map_or_else(crate::auth::generate_token, String::from);
308 write_token_file(&discovery_token);
309
310 tracing::info!("Victauri MCP server listening on 127.0.0.1:{actual_port}");
311
312 let drain_state = state.clone();
313 let drain_bridge = bridge;
314 let drain_shutdown = state.shutdown_tx.subscribe();
315 let drain_finished = state.task_tracker.track("event_drain_loop");
316 tokio::spawn(async move {
317 event_drain_loop(drain_state, drain_bridge, drain_shutdown).await;
318 drain_finished.store(true, std::sync::atomic::Ordering::Relaxed);
319 });
320
321 let mut shutdown_rx2 = shutdown_rx.clone();
322 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
323 let _ = shutdown_rx.wait_for(|&v| v).await;
324 remove_port_file();
325 tracing::info!("Victauri MCP server shutting down gracefully");
326 });
327
328 tokio::select! {
329 result = server => {
330 if let Err(e) = result {
331 tracing::error!("Victauri MCP server error: {e}");
332 }
333 }
334 _ = async {
335 let _ = shutdown_rx2.wait_for(|&v| v).await;
336 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
337 } => {
338 tracing::warn!("Victauri MCP server shutdown timeout — forcing exit");
339 }
340 }
341 Ok(())
342}
343
344async fn try_bind(preferred: u16) -> anyhow::Result<(tokio::net::TcpListener, u16)> {
345 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{preferred}")).await {
346 return Ok((listener, preferred));
347 }
348
349 for offset in 1..=PORT_FALLBACK_RANGE {
350 let Some(port) = preferred.checked_add(offset) else {
353 break;
354 };
355 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await {
356 return Ok((listener, port));
357 }
358 }
359
360 anyhow::bail!(
361 "could not bind to any port in range {preferred}-{}",
362 preferred.saturating_add(PORT_FALLBACK_RANGE)
363 )
364}
365
366fn discovery_dir() -> std::path::PathBuf {
367 std::env::temp_dir()
368 .join("victauri")
369 .join(std::process::id().to_string())
370}
371
372#[cfg(unix)]
373fn current_euid() -> Option<u32> {
374 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
375 use std::sync::atomic::{AtomicU64, Ordering};
376
377 static NEXT_PROBE: AtomicU64 = AtomicU64::new(0);
378 for _ in 0..16 {
379 let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed);
380 let probe = std::env::temp_dir().join(format!(
381 ".victauri_plugin_uidprobe_{}_{}",
382 std::process::id(),
383 sequence
384 ));
385 let file = std::fs::OpenOptions::new()
386 .write(true)
387 .create_new(true)
388 .mode(0o600)
389 .open(&probe)
390 .ok();
391 if let Some(file) = file {
392 let uid = file.metadata().ok().map(|m| m.uid());
393 drop(file);
394 let _ = std::fs::remove_file(probe);
395 if uid.is_some() {
396 return uid;
397 }
398 }
399 }
400 None
401}
402
403#[cfg(unix)]
404fn ensure_unix_private_dir(path: &std::path::Path) -> bool {
405 use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
406
407 let Some(euid) = current_euid() else {
408 return false;
409 };
410 match std::fs::symlink_metadata(path) {
411 Ok(meta) => {
412 if !meta.file_type().is_dir() || meta.uid() != euid {
413 return false;
414 }
415 if std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).is_err() {
416 return false;
417 }
418 }
419 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
420 let mut builder = std::fs::DirBuilder::new();
421 builder.mode(0o700);
422 if builder.create(path).is_err() {
423 return false;
424 }
425 }
426 Err(_) => return false,
427 }
428 unix_private_dir_is_trusted(path)
429}
430
431#[cfg(unix)]
432fn unix_private_dir_is_trusted(path: &std::path::Path) -> bool {
433 use std::os::unix::fs::{MetadataExt, PermissionsExt};
434
435 let Some(euid) = current_euid() else {
436 return false;
437 };
438 std::fs::symlink_metadata(path).is_ok_and(|meta| {
439 meta.file_type().is_dir() && meta.uid() == euid && (meta.permissions().mode() & 0o077) == 0
440 })
441}
442
443#[cfg(windows)]
445#[allow(unsafe_code)]
446fn current_windows_username() -> Option<String> {
447 use windows::Win32::System::WindowsProgramming::GetUserNameW;
448 use windows::core::PWSTR;
449
450 let mut buffer = [0_u16; 257];
451 let mut len = buffer.len() as u32;
452 unsafe {
455 GetUserNameW(Some(PWSTR(buffer.as_mut_ptr())), &raw mut len).ok()?;
456 }
457 let end = buffer
458 .iter()
459 .position(|unit| *unit == 0)
460 .unwrap_or(len as usize);
461 String::from_utf16(&buffer[..end])
462 .ok()
463 .filter(|name| !name.is_empty())
464}
465
466#[cfg(windows)]
468fn to_wide(path: &std::path::Path) -> Vec<u16> {
469 use std::os::windows::ffi::OsStrExt;
470 path.as_os_str().encode_wide().chain(Some(0)).collect()
471}
472
473#[cfg(windows)]
479struct OwnedSid(Vec<u8>);
480
481#[cfg(windows)]
482impl OwnedSid {
483 fn as_psid(&self) -> windows::Win32::Security::PSID {
484 windows::Win32::Security::PSID(self.0.as_ptr() as *mut core::ffi::c_void)
485 }
486}
487
488#[cfg(windows)]
495#[allow(unsafe_code)]
496fn token_sid(class: windows::Win32::Security::TOKEN_INFORMATION_CLASS) -> Option<OwnedSid> {
497 use windows::Win32::Foundation::{CloseHandle, HANDLE};
498 use windows::Win32::Security::{GetLengthSid, GetTokenInformation, PSID, TOKEN_QUERY};
499 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
500
501 struct TokenGuard(HANDLE);
502 impl Drop for TokenGuard {
503 fn drop(&mut self) {
504 unsafe {
506 let _ = CloseHandle(self.0);
507 }
508 }
509 }
510
511 let mut token = HANDLE::default();
512 unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? };
515 let _guard = TokenGuard(token);
516
517 let mut len = 0_u32;
518 unsafe {
521 let _ = GetTokenInformation(token, class, None, 0, &raw mut len);
522 }
523 if len == 0 {
524 return None;
525 }
526 let mut buf = vec![0_u8; len as usize];
527 unsafe {
529 GetTokenInformation(
530 token,
531 class,
532 Some(buf.as_mut_ptr().cast::<core::ffi::c_void>()),
533 len,
534 &raw mut len,
535 )
536 .ok()?;
537 }
538 let sid_ptr = unsafe { *buf.as_ptr().cast::<PSID>() };
541 let sid_len = unsafe { GetLengthSid(sid_ptr) };
543 if sid_len == 0 {
544 return None;
545 }
546 let mut sid = vec![0_u8; sid_len as usize];
547 unsafe {
549 core::ptr::copy_nonoverlapping(sid_ptr.0.cast::<u8>(), sid.as_mut_ptr(), sid_len as usize);
550 }
551 Some(OwnedSid(sid))
552}
553
554#[cfg(windows)]
561fn acceptable_owner_sids() -> Vec<OwnedSid> {
562 use windows::Win32::Security::{TokenOwner, TokenUser};
563 [TokenUser, TokenOwner]
564 .into_iter()
565 .filter_map(token_sid)
566 .collect()
567}
568
569#[cfg(windows)]
576#[allow(unsafe_code)]
577fn dir_owned_by_current_user(path: &std::path::Path) -> bool {
578 use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
579 use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
580 use windows::Win32::Security::{
581 EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
582 };
583 use windows::core::PCWSTR;
584
585 let acceptable = acceptable_owner_sids();
586 if acceptable.is_empty() {
587 return false;
588 }
589 let wide = to_wide(path);
590 let mut owner = PSID::default();
591 let mut psd = PSECURITY_DESCRIPTOR::default();
592 let rc = unsafe {
595 GetNamedSecurityInfoW(
596 PCWSTR(wide.as_ptr()),
597 SE_FILE_OBJECT,
598 OWNER_SECURITY_INFORMATION,
599 Some(&raw mut owner),
600 None,
601 None,
602 None,
603 &raw mut psd,
604 )
605 };
606 if rc != ERROR_SUCCESS {
607 return false;
608 }
609 let owned = acceptable
611 .iter()
612 .any(|sid| unsafe { EqualSid(owner, sid.as_psid()).is_ok() });
613 unsafe {
615 let _ = LocalFree(Some(HLOCAL(psd.0)));
616 }
617 owned
618}
619
620#[cfg(windows)]
629#[allow(unsafe_code)]
630fn apply_owner_only_dacl(path: &std::path::Path) -> bool {
631 use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
632 use windows::Win32::Security::Authorization::{
633 EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW,
634 SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
635 };
636 use windows::Win32::Security::{
637 ACE_FLAGS, ACL, DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION,
638 };
639 use windows::core::PWSTR;
640
641 use windows::Win32::Security::TokenUser;
642
643 const GENERIC_ALL_RIGHTS: u32 = 0x1000_0000;
645 const SUB_CONTAINERS_AND_OBJECTS_INHERIT: u32 = 0x3;
646
647 let Some(me) = token_sid(TokenUser) else {
650 return false;
651 };
652
653 let explicit = EXPLICIT_ACCESS_W {
654 grfAccessPermissions: GENERIC_ALL_RIGHTS,
655 grfAccessMode: SET_ACCESS,
656 grfInheritance: ACE_FLAGS(SUB_CONTAINERS_AND_OBJECTS_INHERIT),
657 Trustee: TRUSTEE_W {
658 pMultipleTrustee: core::ptr::null_mut(),
659 MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE,
660 TrusteeForm: TRUSTEE_IS_SID,
661 TrusteeType: TRUSTEE_IS_USER,
662 ptstrName: PWSTR(me.as_psid().0.cast::<u16>()),
663 },
664 };
665
666 let mut new_acl: *mut ACL = core::ptr::null_mut();
667 let rc = unsafe { SetEntriesInAclW(Some(&[explicit]), None, &raw mut new_acl) };
670 if rc != ERROR_SUCCESS || new_acl.is_null() {
671 return false;
672 }
673
674 let mut wide = to_wide(path);
675 let set_rc = unsafe {
678 SetNamedSecurityInfoW(
679 PWSTR(wide.as_mut_ptr()),
680 SE_FILE_OBJECT,
681 DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
682 None,
683 None,
684 Some(new_acl),
685 None,
686 )
687 };
688 unsafe {
690 let _ = LocalFree(Some(HLOCAL(new_acl.cast::<core::ffi::c_void>())));
691 }
692 set_rc == ERROR_SUCCESS
693}
694
695#[cfg(windows)]
700fn icacls_restrict_to_current_user(path: &std::path::Path) -> bool {
701 let Some(username) = current_windows_username() else {
702 return false;
703 };
704 let path_str = path.to_string_lossy();
705 std::process::Command::new("icacls")
706 .args([
707 &*path_str,
708 "/inheritance:r",
709 "/remove",
710 "*S-1-1-0",
711 "*S-1-5-32-545",
712 "*S-1-5-11",
713 "/grant:r",
714 &format!("{username}:F"),
715 "/q",
716 ])
717 .stdin(std::process::Stdio::null())
718 .stdout(std::process::Stdio::null())
719 .stderr(std::process::Stdio::null())
720 .status()
721 .is_ok_and(|status| status.success())
722}
723
724#[cfg(windows)]
728fn restrict_to_current_user(path: &std::path::Path) -> bool {
729 if apply_owner_only_dacl(path) {
730 return true;
731 }
732 tracing::warn!(
733 "owner-only DACL apply failed for {}; falling back to icacls",
734 path.display()
735 );
736 icacls_restrict_to_current_user(path)
737}
738
739fn ensure_private_dir(dir: &std::path::Path) -> bool {
742 #[cfg(unix)]
743 {
744 let Some(root) = dir.parent() else {
745 return false;
746 };
747 if !ensure_unix_private_dir(root) || !ensure_unix_private_dir(dir) {
748 tracing::warn!("refusing untrusted discovery path {}", dir.display());
749 return false;
750 }
751 }
752 #[cfg(not(unix))]
753 {
754 if std::fs::create_dir_all(dir).is_err() {
755 return false;
756 }
757 #[cfg(windows)]
758 {
759 if !dir_owned_by_current_user(dir) {
764 tracing::warn!(
765 "refusing discovery dir not owned by current user: {}",
766 dir.display()
767 );
768 let _ = std::fs::remove_dir_all(dir);
769 return false;
770 }
771 if !restrict_to_current_user(dir) {
772 let _ = std::fs::remove_dir_all(dir);
773 return false;
774 }
775 }
776 }
777 true
778}
779
780fn write_private_file(path: &std::path::Path, contents: &str) {
785 if std::fs::symlink_metadata(path).is_ok() {
789 let _ = std::fs::remove_file(path);
790 }
791 #[cfg(unix)]
792 let result = {
793 use std::io::Write;
794 use std::os::unix::fs::OpenOptionsExt;
795 std::fs::OpenOptions::new()
796 .write(true)
797 .create_new(true)
798 .mode(0o600)
799 .open(path)
800 .and_then(|mut f| f.write_all(contents.as_bytes()))
801 };
802 #[cfg(not(unix))]
803 let result = {
804 use std::io::Write;
805 std::fs::OpenOptions::new()
806 .write(true)
807 .create_new(true)
808 .open(path)
809 .and_then(|mut f| f.write_all(contents.as_bytes()))
810 };
811 #[cfg(windows)]
816 match result {
817 Ok(()) => {
818 if !restrict_to_current_user(path) {
819 let _ = std::fs::remove_file(path);
820 tracing::warn!("could not restrict discovery file {}", path.display());
821 }
822 }
823 Err(e) => {
824 tracing::debug!("could not write discovery file {}: {e}", path.display());
825 }
826 }
827 #[cfg(not(windows))]
828 if let Err(e) = result {
829 tracing::debug!("could not write discovery file {}: {e}", path.display());
830 }
831}
832
833fn write_port_file(port: u16, identifier: Option<&str>, product_name: Option<&str>) {
834 let dir = discovery_dir();
835 if !ensure_private_dir(&dir) {
836 return;
837 }
838 write_private_file(&dir.join("port"), &port.to_string());
839 let metadata = serde_json::json!({
844 "pid": std::process::id(),
845 "port": port,
846 "identifier": identifier,
847 "product_name": product_name,
848 "started_at": chrono::Utc::now().to_rfc3339(),
849 "version": env!("CARGO_PKG_VERSION"),
850 });
851 write_private_file(&dir.join("metadata.json"), &metadata.to_string());
852}
853
854fn write_token_file(token: &str) {
855 let dir = discovery_dir();
856 if !ensure_private_dir(&dir) {
857 return;
858 }
859 write_private_file(&dir.join("token"), token);
860}
861
862fn remove_port_file() {
863 let dir = discovery_dir();
864 #[cfg(unix)]
865 {
866 let Some(root) = dir.parent() else {
867 return;
868 };
869 if !unix_private_dir_is_trusted(root) || !unix_private_dir_is_trusted(&dir) {
870 return;
871 }
872 }
873 let _ = std::fs::remove_dir_all(dir);
874}
875
876#[must_use]
880pub fn parse_bridge_event(ev: &serde_json::Value) -> Option<victauri_core::AppEvent> {
881 use chrono::Utc;
882 use victauri_core::AppEvent;
883
884 let event_type = ev.get("type").and_then(|t| t.as_str()).unwrap_or("");
885 let now = Utc::now();
886
887 let app_event = match event_type {
888 "console" => AppEvent::Console {
889 level: ev
890 .get("level")
891 .and_then(|l| l.as_str())
892 .unwrap_or("log")
893 .to_string(),
894 message: ev
895 .get("message")
896 .and_then(|m| m.as_str())
897 .unwrap_or("")
898 .to_string(),
899 timestamp: now,
900 },
901 "dom_mutation" => AppEvent::DomMutation {
902 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
903 timestamp: now,
904 mutation_count: ev
905 .get("count")
906 .and_then(serde_json::Value::as_u64)
907 .unwrap_or(0) as u32,
908 },
909 "ipc" => {
910 let cmd = ev
911 .get("command")
912 .and_then(|c| c.as_str())
913 .unwrap_or("unknown");
914 AppEvent::Ipc(victauri_core::IpcCall {
915 id: uuid::Uuid::new_v4().to_string(),
916 command: cmd.to_string(),
917 timestamp: now,
918 result: match ev.get("status").and_then(|s| s.as_str()) {
919 Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null),
920 Some("error") => victauri_core::IpcResult::Err("error".to_string()),
921 _ => victauri_core::IpcResult::Pending,
922 },
923 duration_ms: ev
924 .get("duration_ms")
925 .and_then(serde_json::Value::as_f64)
926 .map(|d| d as u64),
927 arg_size_bytes: 0,
928 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
929 })
930 }
931 "network" => AppEvent::StateChange {
932 key: format!(
933 "network.{}",
934 ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET")
935 ),
936 timestamp: now,
937 caused_by: ev
938 .get("url")
939 .and_then(|u| u.as_str())
940 .map(std::string::ToString::to_string),
941 },
942 "navigation" => AppEvent::WindowEvent {
943 label: DEFAULT_WEBVIEW_LABEL.to_string(),
944 event: format!(
945 "navigation.{}",
946 ev.get("nav_type")
947 .and_then(|n| n.as_str())
948 .unwrap_or("unknown")
949 ),
950 timestamp: now,
951 },
952 "dom_interaction" => {
953 let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click");
954 let action = match action_str {
955 "click" => victauri_core::InteractionKind::Click,
956 "double_click" => victauri_core::InteractionKind::DoubleClick,
957 "fill" => victauri_core::InteractionKind::Fill,
958 "key_press" => victauri_core::InteractionKind::KeyPress,
959 "select" => victauri_core::InteractionKind::Select,
960 "navigate" => victauri_core::InteractionKind::Navigate,
961 "scroll" => victauri_core::InteractionKind::Scroll,
962 _ => victauri_core::InteractionKind::Click,
963 };
964 AppEvent::DomInteraction {
965 action,
966 selector: ev
967 .get("selector")
968 .and_then(|s| s.as_str())
969 .unwrap_or("body")
970 .to_string(),
971 value: ev
972 .get("value")
973 .and_then(|v| v.as_str())
974 .map(std::string::ToString::to_string),
975 timestamp: now,
976 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
977 }
978 }
979 _ => return None,
980 };
981
982 Some(app_event)
983}
984
985async fn event_drain_loop(
986 state: Arc<VictauriState>,
987 bridge: Arc<dyn WebviewBridge>,
988 mut shutdown: tokio::sync::watch::Receiver<bool>,
989) {
990 let mut watermarks: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
996
997 loop {
998 tokio::select! {
999 _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1000 _ = shutdown.changed() => break,
1001 }
1002
1003 if !state.recorder.is_recording() {
1014 continue;
1015 }
1016
1017 let labels = bridge.list_window_labels();
1018 if labels.is_empty() {
1019 continue;
1020 }
1021 watermarks.retain(|label, _| labels.contains(label));
1024
1025 let mut set = tokio::task::JoinSet::new();
1030 for label in &labels {
1031 let since = watermarks.get(label).copied().unwrap_or(0.0);
1032 let state = Arc::clone(&state);
1033 let bridge = Arc::clone(&bridge);
1034 let label = label.clone();
1035 set.spawn(async move {
1036 let newest = drain_window(&state, &bridge, &label, since).await;
1037 (label, newest)
1038 });
1039 }
1040 while let Some(res) = set.join_next().await {
1041 if let Ok((label, Some(newest))) = res {
1042 watermarks.insert(label, newest);
1043 }
1044 }
1045 }
1046}
1047
1048async fn drain_window(
1054 state: &Arc<VictauriState>,
1055 bridge: &Arc<dyn WebviewBridge>,
1056 label: &str,
1057 since: f64,
1058) -> Option<f64> {
1059 let code = format!("return window.__VICTAURI__?.getEventStream({since})");
1060 let id = uuid::Uuid::new_v4().to_string();
1061 let (tx, rx) = tokio::sync::oneshot::channel();
1062
1063 {
1064 let mut pending = state.pending_evals.lock().await;
1065 if pending.len() >= MAX_PENDING_EVALS {
1066 return None;
1067 }
1068 pending.insert(id.clone(), tx);
1069 }
1070
1071 let id_js = super::helpers::js_string(&id);
1072 let inject = format!(
1073 r"
1074 (async () => {{
1075 try {{
1076 const __result = await (async () => {{ {code} }})();
1077 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1078 id: {id_js},
1079 result: JSON.stringify(__result)
1080 }});
1081 }} catch (e) {{
1082 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1083 id: {id_js},
1084 result: JSON.stringify({{ __error: e.message }})
1085 }});
1086 }}
1087 }})();
1088 "
1089 );
1090
1091 if bridge.eval_webview(Some(label), &inject).is_err() {
1092 state.pending_evals.lock().await.remove(&id);
1093 return None;
1094 }
1095
1096 let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await else {
1097 state.pending_evals.lock().await.remove(&id);
1098 return None;
1099 };
1100
1101 let events: Vec<serde_json::Value> = serde_json::from_str(&result).ok()?;
1102
1103 let mut newest = since;
1104 for ev in &events {
1105 let ts = ev
1106 .get("timestamp")
1107 .and_then(serde_json::Value::as_f64)
1108 .unwrap_or(0.0);
1109 if ts > newest {
1110 newest = ts;
1111 }
1112
1113 if let Some(app_event) = parse_bridge_event(ev) {
1114 state.event_log.push(app_event.clone());
1115 if state.recorder.is_recording() {
1116 state.recorder.record_event(app_event);
1117 }
1118 }
1119 }
1120 Some(newest)
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use victauri_core::{AppEvent, InteractionKind, IpcResult};
1127
1128 #[cfg(windows)]
1132 #[test]
1133 fn owner_only_dacl_removes_pre_planted_guests_ace() {
1134 use std::process::Command;
1135 let dir = std::env::temp_dir()
1136 .join("victauri_acl_test")
1137 .join(format!("p{}", std::process::id()));
1138 let _ = std::fs::remove_dir_all(&dir);
1139 std::fs::create_dir_all(&dir).expect("create test dir");
1140
1141 assert!(
1144 dir_owned_by_current_user(&dir),
1145 "a freshly created dir must be recognized as owned by this process"
1146 );
1147
1148 let path_str = dir.to_string_lossy().to_string();
1149
1150 let Ok(grant) = Command::new("icacls")
1152 .args([path_str.as_str(), "/grant", "*S-1-5-32-546:(OI)(CI)F", "/q"])
1153 .output()
1154 else {
1155 let _ = std::fs::remove_dir_all(&dir);
1156 return; };
1158 if !grant.status.success() {
1159 let _ = std::fs::remove_dir_all(&dir);
1160 return; }
1162
1163 let before = Command::new("icacls")
1164 .arg(path_str.as_str())
1165 .output()
1166 .expect("icacls read");
1167 let before_s = String::from_utf8_lossy(&before.stdout);
1168 assert!(
1169 before_s.contains("Guests"),
1170 "pre-condition: the planted Guests ACE should be visible, got:\n{before_s}"
1171 );
1172
1173 assert!(
1175 apply_owner_only_dacl(&dir),
1176 "apply_owner_only_dacl must succeed on a directory we own"
1177 );
1178
1179 let after = Command::new("icacls")
1180 .arg(path_str.as_str())
1181 .output()
1182 .expect("icacls read");
1183 let after_s = String::from_utf8_lossy(&after.stdout);
1184 assert!(
1185 !after_s.contains("Guests"),
1186 "the pre-planted Guests ACE must NOT survive the owner-only DACL, got:\n{after_s}"
1187 );
1188
1189 let _ = std::fs::remove_dir_all(&dir);
1190 }
1191
1192 #[test]
1193 fn normalize_auth_token_collapses_empty() {
1194 assert_eq!(normalize_auth_token(Some(String::new())), None);
1197 assert_eq!(normalize_auth_token(Some(" ".to_string())), None);
1198 assert_eq!(normalize_auth_token(Some("\t\n".to_string())), None);
1199 assert_eq!(
1201 normalize_auth_token(Some("secret-123".to_string())).as_deref(),
1202 Some("secret-123")
1203 );
1204 assert_eq!(normalize_auth_token(None), None);
1205 }
1206
1207 #[tokio::test]
1208 async fn try_bind_preferred_port_available() {
1209 let (listener, port) = try_bind(0).await.unwrap();
1210 let addr = listener.local_addr().unwrap();
1211 assert_eq!(port, 0);
1212 assert_ne!(addr.port(), 0); }
1214
1215 #[tokio::test]
1216 async fn try_bind_falls_back_when_taken() {
1217 let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1218 let blocked_port = blocker.local_addr().unwrap().port();
1219
1220 let (_, actual) = try_bind(blocked_port).await.unwrap();
1221 assert_ne!(actual, blocked_port);
1222 assert!(actual > blocked_port);
1223 assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
1224 }
1225
1226 #[test]
1227 fn port_file_roundtrip() {
1228 write_port_file(7777, Some("com.example.app"), Some("Example"));
1229 let dir = discovery_dir();
1230 let content = std::fs::read_to_string(dir.join("port")).unwrap();
1231 assert_eq!(content, "7777");
1232 let meta: serde_json::Value =
1234 serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
1235 .unwrap();
1236 assert_eq!(meta["port"], 7777);
1237 assert_eq!(meta["pid"], std::process::id());
1238 assert_eq!(meta["identifier"], "com.example.app");
1240 assert_eq!(meta["product_name"], "Example");
1241 remove_port_file();
1242 assert!(!dir.exists());
1243 }
1244
1245 #[cfg(unix)]
1246 #[test]
1247 fn private_dir_refuses_symlink_without_chmodding_target() {
1248 use std::os::unix::fs::PermissionsExt;
1249
1250 let base = tempfile::tempdir().unwrap();
1251 let target = base.path().join("target");
1252 let link = base.path().join("link");
1253 std::fs::create_dir(&target).unwrap();
1254 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1255 std::os::unix::fs::symlink(&target, &link).unwrap();
1256
1257 assert!(!ensure_unix_private_dir(&link));
1258 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1259 assert_eq!(mode, 0o755, "symlink target permissions must be untouched");
1260 }
1261
1262 #[test]
1265 fn parse_dom_interaction_click() {
1266 let ev = serde_json::json!({
1267 "type": "dom_interaction",
1268 "action": "click",
1269 "selector": "#submit-btn",
1270 });
1271 let result = parse_bridge_event(&ev).expect("should produce an event");
1272 match result {
1273 AppEvent::DomInteraction {
1274 action,
1275 selector,
1276 value,
1277 webview_label,
1278 ..
1279 } => {
1280 assert_eq!(action, InteractionKind::Click);
1281 assert_eq!(selector, "#submit-btn");
1282 assert!(value.is_none());
1283 assert_eq!(webview_label, "main");
1284 }
1285 other => panic!("expected DomInteraction, got {other:?}"),
1286 }
1287 }
1288
1289 #[test]
1290 fn parse_dom_interaction_fill_with_value() {
1291 let ev = serde_json::json!({
1292 "type": "dom_interaction",
1293 "action": "fill",
1294 "selector": "input[name=email]",
1295 "value": "test@example.com",
1296 });
1297 let result = parse_bridge_event(&ev).expect("should produce an event");
1298 match result {
1299 AppEvent::DomInteraction {
1300 action,
1301 selector,
1302 value,
1303 ..
1304 } => {
1305 assert_eq!(action, InteractionKind::Fill);
1306 assert_eq!(selector, "input[name=email]");
1307 assert_eq!(value.as_deref(), Some("test@example.com"));
1308 }
1309 other => panic!("expected DomInteraction, got {other:?}"),
1310 }
1311 }
1312
1313 #[test]
1314 fn parse_dom_interaction_key_press() {
1315 let ev = serde_json::json!({
1316 "type": "dom_interaction",
1317 "action": "key_press",
1318 "selector": "body",
1319 "value": "Enter",
1320 });
1321 let result = parse_bridge_event(&ev).expect("should produce an event");
1322 match result {
1323 AppEvent::DomInteraction { action, value, .. } => {
1324 assert_eq!(action, InteractionKind::KeyPress);
1325 assert_eq!(value.as_deref(), Some("Enter"));
1326 }
1327 other => panic!("expected DomInteraction, got {other:?}"),
1328 }
1329 }
1330
1331 #[test]
1332 fn parse_dom_interaction_unknown_action_defaults_to_click() {
1333 let ev = serde_json::json!({
1334 "type": "dom_interaction",
1335 "action": "swipe_left",
1336 "selector": ".card",
1337 });
1338 let result = parse_bridge_event(&ev).expect("should produce an event");
1339 match result {
1340 AppEvent::DomInteraction { action, .. } => {
1341 assert_eq!(action, InteractionKind::Click);
1342 }
1343 other => panic!("expected DomInteraction, got {other:?}"),
1344 }
1345 }
1346
1347 #[test]
1348 fn parse_dom_interaction_missing_action_defaults_to_click() {
1349 let ev = serde_json::json!({
1350 "type": "dom_interaction",
1351 "selector": "button",
1352 });
1353 let result = parse_bridge_event(&ev).expect("should produce an event");
1354 match result {
1355 AppEvent::DomInteraction { action, .. } => {
1356 assert_eq!(action, InteractionKind::Click);
1357 }
1358 other => panic!("expected DomInteraction, got {other:?}"),
1359 }
1360 }
1361
1362 #[test]
1363 fn parse_dom_interaction_missing_selector_defaults_to_body() {
1364 let ev = serde_json::json!({
1365 "type": "dom_interaction",
1366 "action": "scroll",
1367 });
1368 let result = parse_bridge_event(&ev).expect("should produce an event");
1369 match result {
1370 AppEvent::DomInteraction {
1371 action, selector, ..
1372 } => {
1373 assert_eq!(action, InteractionKind::Scroll);
1374 assert_eq!(selector, "body");
1375 }
1376 other => panic!("expected DomInteraction, got {other:?}"),
1377 }
1378 }
1379
1380 #[test]
1381 fn parse_dom_interaction_all_action_kinds() {
1382 let cases = [
1383 ("click", InteractionKind::Click),
1384 ("double_click", InteractionKind::DoubleClick),
1385 ("fill", InteractionKind::Fill),
1386 ("key_press", InteractionKind::KeyPress),
1387 ("select", InteractionKind::Select),
1388 ("navigate", InteractionKind::Navigate),
1389 ("scroll", InteractionKind::Scroll),
1390 ];
1391 for (action_str, expected_kind) in cases {
1392 let ev = serde_json::json!({
1393 "type": "dom_interaction",
1394 "action": action_str,
1395 "selector": "body",
1396 });
1397 let result = parse_bridge_event(&ev)
1398 .unwrap_or_else(|| panic!("should produce event for action {action_str}"));
1399 match result {
1400 AppEvent::DomInteraction { action, .. } => {
1401 assert_eq!(action, expected_kind, "mismatch for action {action_str}");
1402 }
1403 other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
1404 }
1405 }
1406 }
1407
1408 #[test]
1411 fn parse_ipc_status_ok() {
1412 let ev = serde_json::json!({
1413 "type": "ipc",
1414 "command": "greet",
1415 "status": "ok",
1416 "duration_ms": 42.0,
1417 });
1418 let result = parse_bridge_event(&ev).expect("should produce an event");
1419 match result {
1420 AppEvent::Ipc(call) => {
1421 assert_eq!(call.command, "greet");
1422 assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
1423 assert_eq!(call.duration_ms, Some(42));
1424 assert_eq!(call.webview_label, "main");
1425 }
1426 other => panic!("expected Ipc, got {other:?}"),
1427 }
1428 }
1429
1430 #[test]
1431 fn parse_ipc_status_error() {
1432 let ev = serde_json::json!({
1433 "type": "ipc",
1434 "command": "save_file",
1435 "status": "error",
1436 });
1437 let result = parse_bridge_event(&ev).expect("should produce an event");
1438 match result {
1439 AppEvent::Ipc(call) => {
1440 assert_eq!(call.command, "save_file");
1441 assert_eq!(call.result, IpcResult::Err("error".to_string()));
1442 }
1443 other => panic!("expected Ipc, got {other:?}"),
1444 }
1445 }
1446
1447 #[test]
1448 fn parse_ipc_status_pending() {
1449 let ev = serde_json::json!({
1450 "type": "ipc",
1451 "command": "long_task",
1452 });
1453 let result = parse_bridge_event(&ev).expect("should produce an event");
1454 match result {
1455 AppEvent::Ipc(call) => {
1456 assert_eq!(call.result, IpcResult::Pending);
1457 assert!(call.duration_ms.is_none());
1458 }
1459 other => panic!("expected Ipc, got {other:?}"),
1460 }
1461 }
1462
1463 #[test]
1466 fn parse_console_event() {
1467 let ev = serde_json::json!({
1468 "type": "console",
1469 "level": "warn",
1470 "message": "deprecated API usage",
1471 });
1472 let result = parse_bridge_event(&ev).expect("should produce an event");
1473 match result {
1474 AppEvent::Console { level, message, .. } => {
1475 assert_eq!(level, "warn");
1476 assert_eq!(message, "deprecated API usage");
1477 }
1478 other => panic!("expected Console, got {other:?}"),
1479 }
1480 }
1481
1482 #[test]
1483 fn parse_console_default_level() {
1484 let ev = serde_json::json!({
1485 "type": "console",
1486 "message": "hello",
1487 });
1488 let result = parse_bridge_event(&ev).expect("should produce an event");
1489 match result {
1490 AppEvent::Console { level, message, .. } => {
1491 assert_eq!(level, "log");
1492 assert_eq!(message, "hello");
1493 }
1494 other => panic!("expected Console, got {other:?}"),
1495 }
1496 }
1497
1498 #[test]
1501 fn parse_navigation_event() {
1502 let ev = serde_json::json!({
1503 "type": "navigation",
1504 "nav_type": "push",
1505 });
1506 let result = parse_bridge_event(&ev).expect("should produce an event");
1507 match result {
1508 AppEvent::WindowEvent { label, event, .. } => {
1509 assert_eq!(label, "main");
1510 assert_eq!(event, "navigation.push");
1511 }
1512 other => panic!("expected WindowEvent, got {other:?}"),
1513 }
1514 }
1515
1516 #[test]
1517 fn parse_navigation_default_nav_type() {
1518 let ev = serde_json::json!({ "type": "navigation" });
1519 let result = parse_bridge_event(&ev).expect("should produce an event");
1520 match result {
1521 AppEvent::WindowEvent { event, .. } => {
1522 assert_eq!(event, "navigation.unknown");
1523 }
1524 other => panic!("expected WindowEvent, got {other:?}"),
1525 }
1526 }
1527
1528 #[test]
1531 fn parse_dom_mutation_event() {
1532 let ev = serde_json::json!({
1533 "type": "dom_mutation",
1534 "count": 15,
1535 });
1536 let result = parse_bridge_event(&ev).expect("should produce an event");
1537 match result {
1538 AppEvent::DomMutation {
1539 webview_label,
1540 mutation_count,
1541 ..
1542 } => {
1543 assert_eq!(webview_label, "main");
1544 assert_eq!(mutation_count, 15);
1545 }
1546 other => panic!("expected DomMutation, got {other:?}"),
1547 }
1548 }
1549
1550 #[test]
1553 fn parse_network_event() {
1554 let ev = serde_json::json!({
1555 "type": "network",
1556 "method": "POST",
1557 "url": "https://api.example.com/data",
1558 });
1559 let result = parse_bridge_event(&ev).expect("should produce an event");
1560 match result {
1561 AppEvent::StateChange { key, caused_by, .. } => {
1562 assert_eq!(key, "network.POST");
1563 assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
1564 }
1565 other => panic!("expected StateChange, got {other:?}"),
1566 }
1567 }
1568
1569 #[test]
1572 fn parse_unknown_type_returns_none() {
1573 let ev = serde_json::json!({
1574 "type": "custom_telemetry",
1575 "payload": 42,
1576 });
1577 assert!(parse_bridge_event(&ev).is_none());
1578 }
1579
1580 #[test]
1581 fn parse_missing_type_field_returns_none() {
1582 let ev = serde_json::json!({ "data": "no type here" });
1583 assert!(parse_bridge_event(&ev).is_none());
1584 }
1585
1586 #[test]
1587 fn parse_empty_object_returns_none() {
1588 let ev = serde_json::json!({});
1589 assert!(parse_bridge_event(&ev).is_none());
1590 }
1591}