use std::sync::Arc;
use std::sync::atomic::Ordering;
use axum::extract::DefaultBodyLimit;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use tauri::Runtime;
use tower::limit::GlobalConcurrencyLimitLayer;
use crate::VictauriState;
use crate::bridge::WebviewBridge;
use super::{MAX_PENDING_EVALS, VictauriMcpHandler};
const DEFAULT_WEBVIEW_LABEL: &str = "main";
pub fn build_app(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> axum::Router {
build_app_with_options(state, bridge, None)
}
#[must_use]
fn normalize_auth_token(auth_token: Option<String>) -> Option<String> {
match auth_token {
Some(t) if t.trim().is_empty() => {
tracing::warn!(
"Victauri: configured auth token is empty/whitespace — treating as NO auth. \
Set a non-empty VICTAURI_AUTH_TOKEN / auth_token(), or use auth_disabled() \
to intentionally run without authentication."
);
None
}
other => other,
}
}
async fn backfill_stateless_session_id(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let mut resp = next.run(req).await;
resp.headers_mut()
.entry(axum::http::HeaderName::from_static("mcp-session-id"))
.or_insert(axum::http::HeaderValue::from_static("stateless"));
resp
}
pub fn build_app_with_options(
state: Arc<VictauriState>,
bridge: Arc<dyn WebviewBridge>,
auth_token: Option<String>,
) -> axum::Router {
build_app_full(state, bridge, auth_token, None)
}
pub fn build_app_full(
state: Arc<VictauriState>,
bridge: Arc<dyn WebviewBridge>,
auth_token: Option<String>,
rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
) -> axum::Router {
build_app_full_inner(state, bridge, auth_token, rate_limiter, false)
}
#[doc(hidden)]
pub fn build_app_stateful(
state: Arc<VictauriState>,
bridge: Arc<dyn WebviewBridge>,
auth_token: Option<String>,
) -> axum::Router {
build_app_full_inner(state, bridge, auth_token, None, true)
}
fn build_app_full_inner(
state: Arc<VictauriState>,
bridge: Arc<dyn WebviewBridge>,
auth_token: Option<String>,
rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
stateful: bool,
) -> axum::Router {
let auth_token = normalize_auth_token(auth_token);
let tauri_cfg = bridge.tauri_config();
let app_identifier = tauri_cfg
.get("identifier")
.and_then(|v| v.as_str())
.map(String::from);
let app_product_name = tauri_cfg
.get("product_name")
.and_then(|v| v.as_str())
.map(String::from);
let handler = VictauriMcpHandler::new(state.clone(), bridge);
let rest = super::rest::router(handler.clone());
let mcp_config = if stateful {
StreamableHttpServerConfig::default()
} else {
StreamableHttpServerConfig::default()
.with_legacy_session_mode(false)
.with_json_response(true)
}
.with_max_request_body_bytes(super::hardening::MAX_REQUEST_BODY_BYTES);
let mcp_service = StreamableHttpService::new(
move || Ok(handler.clone()),
Arc::new(LocalSessionManager::default()),
mcp_config,
);
let auth_state = Arc::new(crate::auth::AuthState {
token: auth_token.clone(),
});
let info_state = state.clone();
let info_auth = auth_token.is_some();
let privacy_enabled = !state.privacy.disabled_tools.is_empty()
|| state.privacy.command_allowlist.is_some()
|| !state.privacy.command_blocklist.is_empty()
|| state.privacy.redaction_enabled;
let mut mcp_router = axum::Router::new().route_service("/mcp", mcp_service);
if !stateful {
mcp_router = mcp_router.layer(axum::middleware::from_fn(backfill_stateless_session_id));
}
let mut router = mcp_router
.nest("/api/tools", rest)
.route(
"/info",
axum::routing::get(move || {
let s = info_state.clone();
let app_id = app_identifier.clone();
let app_name = app_product_name.clone();
async move {
axum::Json(serde_json::json!({
"name": "victauri",
"description": "Full-stack Tauri app inspection: webview + IPC + Rust backend + SQLite",
"version": env!("CARGO_PKG_VERSION"),
"protocol": "mcp",
"app_identifier": app_id,
"app_product_name": app_name,
"capabilities": ["webview", "ipc", "backend", "database", "filesystem"],
"commands_registered": s.registry.count(),
"events_captured": s.event_log.len(),
"port": s.port.load(Ordering::Relaxed),
"auth_required": info_auth,
"privacy_mode": privacy_enabled,
}))
}
}),
);
router = router.layer(axum::middleware::from_fn(
super::hardening::read_body_with_deadline,
));
if auth_token.is_some() {
router = router.layer(axum::middleware::from_fn_with_state(
auth_state,
crate::auth::require_auth,
));
}
router = router
.layer(DefaultBodyLimit::max(
super::hardening::MAX_REQUEST_BODY_BYTES,
))
.layer(GlobalConcurrencyLimitLayer::new(64));
router = router.route(
"/health",
axum::routing::get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }),
);
let limiter = rate_limiter.unwrap_or_else(crate::auth::default_rate_limiter);
let authenticated = Arc::new(crate::auth::RateLimiterState::new(limiter.max_tokens()));
router = router.layer(axum::middleware::from_fn_with_state(
Arc::new(super::hardening::SplitRateLimit {
token: auth_token,
public: limiter,
authenticated,
}),
super::hardening::split_rate_limit,
));
router
.layer(axum::middleware::from_fn(require_json_from_browsers))
.layer(axum::middleware::from_fn(crate::auth::security_headers))
.layer(axum::middleware::from_fn(crate::auth::origin_guard))
.layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
.layer(axum::middleware::from_fn(
super::hardening::close_on_rejection,
))
}
async fn require_json_from_browsers(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
use axum::http::{Method, StatusCode, header};
use axum::response::IntoResponse;
let has_body_method = !matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS);
if has_body_method && req.headers().contains_key(header::ORIGIN) {
let is_json = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.and_then(|ct| ct.split(';').next())
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
if !is_json {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"browser-originated requests must send Content-Type: application/json",
)
.into_response();
}
}
next.run(req).await
}
#[doc(hidden)]
#[allow(dead_code)]
pub mod tests_support {
#[must_use]
pub fn get_memory_stats() -> serde_json::Value {
crate::memory::current_stats()
}
}
const PORT_FALLBACK_RANGE: u16 = 10;
pub async fn start_server<R: Runtime>(
app_handle: tauri::AppHandle<R>,
state: Arc<VictauriState>,
port: u16,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> anyhow::Result<()> {
start_server_with_options(app_handle, state, port, None, shutdown_rx).await
}
pub async fn start_server_with_options<R: Runtime>(
app_handle: tauri::AppHandle<R>,
state: Arc<VictauriState>,
port: u16,
auth_token: Option<String>,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> anyhow::Result<()> {
start_server_reporting_port(app_handle, state, port, auth_token, shutdown_rx, None).await
}
#[doc(hidden)]
pub async fn start_server_reporting_port<R: Runtime>(
app_handle: tauri::AppHandle<R>,
state: Arc<VictauriState>,
port: u16,
auth_token: Option<String>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
bound: Option<tokio::sync::oneshot::Sender<Result<u16, String>>>,
) -> anyhow::Result<()> {
let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
std::mem::forget(Arc::clone(&bridge));
let auth_token = normalize_auth_token(auth_token);
let token_for_file = auth_token.clone();
let app = build_app_with_options(state.clone(), bridge.clone(), auth_token);
let (listener, actual_port) = match try_bind(port).await {
Ok(bound_listener) => bound_listener,
Err(e) => {
if let Some(bound) = bound {
let _ = bound.send(Err(e.to_string()));
}
return Err(e);
}
};
if actual_port != port {
tracing::warn!("Victauri: port {port} in use, fell back to {actual_port}");
}
state.port.store(actual_port, Ordering::Relaxed);
let cfg = bridge.tauri_config();
let app_identifier = cfg.get("identifier").and_then(|v| v.as_str());
let app_product_name = cfg.get("product_name").and_then(|v| v.as_str());
let discovery_token = token_for_file
.as_deref()
.map_or_else(crate::auth::generate_token, String::from);
write_token_file(&discovery_token);
write_port_file(actual_port, app_identifier, app_product_name);
if let Some(bound) = bound {
let _ = bound.send(Ok(actual_port));
}
tracing::info!("Victauri MCP server listening on 127.0.0.1:{actual_port}");
let drain_state = state.clone();
let drain_bridge = bridge;
let drain_shutdown = state.shutdown_tx.subscribe();
let drain_finished = state.task_tracker.track_guarded("event_drain_loop");
tokio::spawn(async move {
let _finished = drain_finished;
event_drain_loop(drain_state, drain_bridge, drain_shutdown).await;
});
let mut shutdown_rx2 = shutdown_rx.clone();
let server = super::hardening::serve_hardened(
listener,
app,
super::hardening::ServeLimits::DEFAULT,
async move {
let _ = shutdown_rx.wait_for(|&v| v).await;
remove_port_file();
tracing::info!("Victauri MCP server shutting down gracefully");
},
);
tokio::select! {
() = server => {}
_ = async {
let _ = shutdown_rx2.wait_for(|&v| v).await;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
} => {
tracing::warn!("Victauri MCP server shutdown timeout — forcing exit");
}
}
Ok(())
}
async fn try_bind(preferred: u16) -> anyhow::Result<(tokio::net::TcpListener, u16)> {
if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{preferred}")).await {
return Ok((listener, preferred));
}
for offset in 1..=PORT_FALLBACK_RANGE {
let Some(port) = preferred.checked_add(offset) else {
break;
};
if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await {
return Ok((listener, port));
}
}
anyhow::bail!(
"could not bind to any port in range {preferred}-{}",
preferred.saturating_add(PORT_FALLBACK_RANGE)
)
}
fn discovery_dir() -> std::path::PathBuf {
discovery_root().join(std::process::id().to_string())
}
fn discovery_root() -> std::path::PathBuf {
static ROOT: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
ROOT.get_or_init(|| {
#[cfg(unix)]
{
let euid = current_euid();
select_discovery_root(
&discovery_root_from(std::env::var_os("XDG_RUNTIME_DIR"), euid),
euid.and_then(|_| {
home_state_root(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME"))
}),
)
}
#[cfg(not(unix))]
{
std::env::temp_dir().join("victauri")
}
})
.clone()
}
#[cfg(unix)]
fn select_discovery_root(
primary: &std::path::Path,
fallback: Option<std::path::PathBuf>,
) -> std::path::PathBuf {
if ensure_unix_private_dir(primary) {
return primary.to_path_buf();
}
if let Some(fallback) = fallback
&& ensure_home_state_root(&fallback)
{
tracing::warn!(
"discovery root {} is not usable (another user may have created it); \
registering under {} instead",
primary.display(),
fallback.display()
);
return fallback;
}
primary.to_path_buf()
}
#[cfg(unix)]
fn home_state_root(
xdg_state_home: Option<std::ffi::OsString>,
home: Option<std::ffi::OsString>,
) -> Option<std::path::PathBuf> {
let absolute =
|p: std::ffi::OsString| Some(std::path::PathBuf::from(p)).filter(|p| p.is_absolute());
xdg_state_home
.and_then(absolute)
.or_else(|| {
home.and_then(absolute)
.map(|h| h.join(".local").join("state"))
})
.map(|state| state.join("victauri"))
}
#[cfg(unix)]
fn ensure_home_state_root(root: &std::path::Path) -> bool {
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
let (Some(parent), Some(euid)) = (root.parent(), current_euid()) else {
return false;
};
if std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)
.is_err()
{
return false;
}
let parent_ok = std::fs::metadata(parent)
.is_ok_and(|m| m.is_dir() && m.uid() == euid && m.mode() & 0o002 == 0);
parent_ok && ensure_unix_private_dir(root)
}
#[cfg(unix)]
fn discovery_root_from(
xdg_runtime_dir: Option<std::ffi::OsString>,
euid: Option<u32>,
) -> std::path::PathBuf {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let Some(euid) = euid else {
return std::env::temp_dir().join("victauri");
};
let private_runtime_dir = xdg_runtime_dir
.map(std::path::PathBuf::from)
.filter(|dir| dir.is_absolute())
.filter(|dir| {
std::fs::symlink_metadata(dir).is_ok_and(|m| {
m.file_type().is_dir() && m.uid() == euid && m.permissions().mode() & 0o077 == 0
})
});
private_runtime_dir.map_or_else(
|| std::env::temp_dir().join(format!("victauri-{euid}")),
|dir| dir.join("victauri"),
)
}
#[cfg(unix)]
fn current_euid() -> Option<u32> {
current_euid_in(&std::env::temp_dir())
}
#[cfg(unix)]
fn current_euid_in(dir: &std::path::Path) -> Option<u32> {
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
for _ in 0..16 {
let probe = dir.join(format!(
".victauri_plugin_uidprobe_{}",
uuid::Uuid::new_v4().simple()
));
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&probe)
.ok();
if let Some(file) = file {
let uid = file.metadata().ok().map(|m| m.uid());
drop(file);
let _ = std::fs::remove_file(probe);
if uid.is_some() {
return uid;
}
}
}
None
}
#[cfg(unix)]
fn ensure_unix_private_dir(path: &std::path::Path) -> bool {
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
let Some(euid) = current_euid() else {
return false;
};
match std::fs::symlink_metadata(path) {
Ok(meta) => {
if !meta.file_type().is_dir() || meta.uid() != euid {
return false;
}
if std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).is_err() {
return false;
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let mut builder = std::fs::DirBuilder::new();
builder.mode(0o700);
if builder.create(path).is_err() {
return false;
}
}
Err(_) => return false,
}
unix_private_dir_is_trusted(path)
}
#[cfg(unix)]
fn unix_private_dir_is_trusted(path: &std::path::Path) -> bool {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let Some(euid) = current_euid() else {
return false;
};
std::fs::symlink_metadata(path).is_ok_and(|meta| {
meta.file_type().is_dir() && meta.uid() == euid && (meta.permissions().mode() & 0o077) == 0
})
}
#[cfg(windows)]
#[allow(unsafe_code)]
fn current_windows_username() -> Option<String> {
use windows::Win32::System::WindowsProgramming::GetUserNameW;
use windows::core::PWSTR;
let mut buffer = [0_u16; 257];
let mut len = buffer.len() as u32;
unsafe {
GetUserNameW(Some(PWSTR(buffer.as_mut_ptr())), &raw mut len).ok()?;
}
let end = buffer
.iter()
.position(|unit| *unit == 0)
.unwrap_or(len as usize);
String::from_utf16(&buffer[..end])
.ok()
.filter(|name| !name.is_empty())
}
#[cfg(windows)]
fn to_wide(path: &std::path::Path) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
path.as_os_str().encode_wide().chain(Some(0)).collect()
}
#[cfg(windows)]
struct OwnedSid(Vec<u8>);
#[cfg(windows)]
impl OwnedSid {
fn as_psid(&self) -> windows::Win32::Security::PSID {
windows::Win32::Security::PSID(self.0.as_ptr() as *mut core::ffi::c_void)
}
}
#[cfg(windows)]
#[allow(unsafe_code)]
fn token_sid(class: windows::Win32::Security::TOKEN_INFORMATION_CLASS) -> Option<OwnedSid> {
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Security::{GetLengthSid, GetTokenInformation, PSID, TOKEN_QUERY};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
struct TokenGuard(HANDLE);
impl Drop for TokenGuard {
fn drop(&mut self) {
unsafe {
let _ = CloseHandle(self.0);
}
}
}
let mut token = HANDLE::default();
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? };
let _guard = TokenGuard(token);
let mut len = 0_u32;
unsafe {
let _ = GetTokenInformation(token, class, None, 0, &raw mut len);
}
if len == 0 {
return None;
}
let mut buf = vec![0_u8; len as usize];
unsafe {
GetTokenInformation(
token,
class,
Some(buf.as_mut_ptr().cast::<core::ffi::c_void>()),
len,
&raw mut len,
)
.ok()?;
}
let sid_ptr = unsafe { *buf.as_ptr().cast::<PSID>() };
let sid_len = unsafe { GetLengthSid(sid_ptr) };
if sid_len == 0 {
return None;
}
let mut sid = vec![0_u8; sid_len as usize];
unsafe {
core::ptr::copy_nonoverlapping(sid_ptr.0.cast::<u8>(), sid.as_mut_ptr(), sid_len as usize);
}
Some(OwnedSid(sid))
}
#[cfg(windows)]
fn acceptable_owner_sids() -> Vec<OwnedSid> {
use windows::Win32::Security::{TokenOwner, TokenUser};
let mut sids: Vec<OwnedSid> = [TokenUser, TokenOwner]
.into_iter()
.filter_map(token_sid)
.collect();
sids.extend(builtin_administrators_sid_if_member());
sids
}
#[cfg(windows)]
#[allow(unsafe_code)]
fn builtin_administrators_sid_if_member() -> Option<OwnedSid> {
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Security::{
CreateWellKnownSid, EqualSid, GetTokenInformation, PSID, SECURITY_MAX_SID_SIZE,
TOKEN_GROUPS, TOKEN_QUERY, TokenGroups, WinBuiltinAdministratorsSid,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
struct TokenGuard(HANDLE);
impl Drop for TokenGuard {
fn drop(&mut self) {
unsafe {
let _ = CloseHandle(self.0);
}
}
}
let mut admins = vec![0_u8; SECURITY_MAX_SID_SIZE as usize];
let mut admins_len = SECURITY_MAX_SID_SIZE;
unsafe {
CreateWellKnownSid(
WinBuiltinAdministratorsSid,
None,
Some(PSID(admins.as_mut_ptr().cast::<core::ffi::c_void>())),
&raw mut admins_len,
)
.ok()?;
}
admins.truncate(admins_len as usize);
let admins = OwnedSid(admins);
let mut token = HANDLE::default();
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? };
let _guard = TokenGuard(token);
let mut len = 0_u32;
unsafe {
let _ = GetTokenInformation(token, TokenGroups, None, 0, &raw mut len);
}
if (len as usize) < std::mem::size_of::<TOKEN_GROUPS>() {
return None;
}
let mut buf = vec![0_u64; (len as usize).div_ceil(8)];
unsafe {
GetTokenInformation(
token,
TokenGroups,
Some(buf.as_mut_ptr().cast::<core::ffi::c_void>()),
len,
&raw mut len,
)
.ok()?;
}
let member = unsafe {
let groups = &*buf.as_ptr().cast::<TOKEN_GROUPS>();
std::slice::from_raw_parts(groups.Groups.as_ptr(), groups.GroupCount as usize)
.iter()
.any(|g| EqualSid(g.Sid, admins.as_psid()).is_ok())
};
member.then_some(admins)
}
#[cfg(windows)]
#[allow(unsafe_code)]
fn dir_owned_by_current_user(path: &std::path::Path) -> bool {
use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::{
EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
};
use windows::core::PCWSTR;
let acceptable = acceptable_owner_sids();
if acceptable.is_empty() {
return false;
}
let wide = to_wide(path);
let mut owner = PSID::default();
let mut psd = PSECURITY_DESCRIPTOR::default();
let rc = unsafe {
GetNamedSecurityInfoW(
PCWSTR(wide.as_ptr()),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&raw mut owner),
None,
None,
None,
&raw mut psd,
)
};
if rc != ERROR_SUCCESS {
return false;
}
let owned = acceptable
.iter()
.any(|sid| unsafe { EqualSid(owner, sid.as_psid()).is_ok() });
unsafe {
let _ = LocalFree(Some(HLOCAL(psd.0)));
}
owned
}
#[cfg(windows)]
#[allow(unsafe_code)]
fn apply_owner_only_dacl(path: &std::path::Path) -> bool {
use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
use windows::Win32::Security::Authorization::{
EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW,
SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
};
use windows::Win32::Security::{
ACE_FLAGS, ACL, DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION,
};
use windows::core::PWSTR;
use windows::Win32::Security::TokenUser;
const GENERIC_ALL_RIGHTS: u32 = 0x1000_0000;
const SUB_CONTAINERS_AND_OBJECTS_INHERIT: u32 = 0x3;
let Some(me) = token_sid(TokenUser) else {
return false;
};
let explicit = EXPLICIT_ACCESS_W {
grfAccessPermissions: GENERIC_ALL_RIGHTS,
grfAccessMode: SET_ACCESS,
grfInheritance: ACE_FLAGS(SUB_CONTAINERS_AND_OBJECTS_INHERIT),
Trustee: TRUSTEE_W {
pMultipleTrustee: core::ptr::null_mut(),
MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE,
TrusteeForm: TRUSTEE_IS_SID,
TrusteeType: TRUSTEE_IS_USER,
ptstrName: PWSTR(me.as_psid().0.cast::<u16>()),
},
};
let mut new_acl: *mut ACL = core::ptr::null_mut();
let rc = unsafe { SetEntriesInAclW(Some(&[explicit]), None, &raw mut new_acl) };
if rc != ERROR_SUCCESS || new_acl.is_null() {
return false;
}
let mut wide = to_wide(path);
let set_rc = unsafe {
SetNamedSecurityInfoW(
PWSTR(wide.as_mut_ptr()),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
None,
None,
Some(new_acl),
None,
)
};
unsafe {
let _ = LocalFree(Some(HLOCAL(new_acl.cast::<core::ffi::c_void>())));
}
set_rc == ERROR_SUCCESS
}
#[cfg(windows)]
fn icacls_restrict_to_current_user(path: &std::path::Path) -> bool {
let Some(username) = current_windows_username() else {
return false;
};
let path_str = path.to_string_lossy();
std::process::Command::new("icacls")
.args([
&*path_str,
"/inheritance:r",
"/remove",
"*S-1-1-0",
"*S-1-5-32-545",
"*S-1-5-11",
"/grant:r",
&format!("{username}:F"),
"/q",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
#[cfg(windows)]
fn restrict_to_current_user(path: &std::path::Path) -> bool {
if apply_owner_only_dacl(path) {
return true;
}
tracing::warn!(
"owner-only DACL apply failed for {}; falling back to icacls",
path.display()
);
icacls_restrict_to_current_user(path)
}
#[cfg(windows)]
fn ensure_windows_private_dir(path: &std::path::Path, remove_on_acl_failure: bool) -> bool {
let mut created = false;
match std::fs::symlink_metadata(path) {
Ok(meta) => {
if !meta.file_type().is_dir() {
return false;
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if std::fs::create_dir(path).is_err() {
return false;
}
created = true;
}
Err(_) => return false,
}
if !dir_owned_by_current_user(path) {
tracing::warn!(
"refusing discovery directory not owned by current user: {}",
path.display()
);
if created || remove_on_acl_failure {
let _ = std::fs::remove_dir_all(path);
}
return false;
}
if !restrict_to_current_user(path) {
if created || remove_on_acl_failure {
let _ = std::fs::remove_dir_all(path);
}
return false;
}
true
}
fn ensure_private_dir(dir: &std::path::Path) -> bool {
#[cfg(unix)]
{
let Some(root) = dir.parent() else {
return false;
};
if !ensure_unix_private_dir(root) || !ensure_unix_private_dir(dir) {
tracing::warn!("refusing untrusted discovery path {}", dir.display());
return false;
}
}
#[cfg(windows)]
{
let Some(root) = dir.parent() else {
return false;
};
if !ensure_windows_private_dir(root, false) {
tracing::warn!("refusing untrusted discovery root {}", root.display());
return false;
}
if !ensure_windows_private_dir(dir, true) {
tracing::warn!("refusing untrusted discovery path {}", dir.display());
return false;
}
}
#[cfg(all(not(unix), not(windows)))]
if std::fs::create_dir_all(dir).is_err() {
return false;
}
true
}
fn write_private_file(path: &std::path::Path, contents: &str) {
if std::fs::symlink_metadata(path).is_ok() {
let _ = std::fs::remove_file(path);
}
#[cfg(unix)]
let result = {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.and_then(|mut f| f.write_all(contents.as_bytes()))
};
#[cfg(not(unix))]
let result = {
use std::io::Write;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.and_then(|mut f| f.write_all(contents.as_bytes()))
};
#[cfg(windows)]
match result {
Ok(()) => {
if !restrict_to_current_user(path) {
let _ = std::fs::remove_file(path);
tracing::warn!("could not restrict discovery file {}", path.display());
}
}
Err(e) => {
tracing::debug!("could not write discovery file {}: {e}", path.display());
}
}
#[cfg(not(windows))]
if let Err(e) = result {
tracing::debug!("could not write discovery file {}: {e}", path.display());
}
}
fn write_port_file(port: u16, identifier: Option<&str>, product_name: Option<&str>) {
let dir = discovery_dir();
if !ensure_private_dir(&dir) {
return;
}
write_private_file(&dir.join("port"), &port.to_string());
let metadata = serde_json::json!({
"pid": std::process::id(),
"port": port,
"identifier": identifier,
"product_name": product_name,
"started_at": chrono::Utc::now().to_rfc3339(),
"version": env!("CARGO_PKG_VERSION"),
});
write_private_file(&dir.join("metadata.json"), &metadata.to_string());
}
fn write_token_file(token: &str) {
let dir = discovery_dir();
if !ensure_private_dir(&dir) {
return;
}
write_private_file(&dir.join("token"), token);
}
fn remove_port_file() {
let dir = discovery_dir();
#[cfg(unix)]
{
let Some(root) = dir.parent() else {
return;
};
if !unix_private_dir_is_trusted(root) || !unix_private_dir_is_trusted(&dir) {
return;
}
}
let _ = std::fs::remove_dir_all(dir);
}
#[must_use]
pub fn parse_bridge_event(ev: &serde_json::Value) -> Option<victauri_core::AppEvent> {
parse_bridge_event_from(ev, DEFAULT_WEBVIEW_LABEL)
}
pub(super) fn parse_bridge_event_from(
ev: &serde_json::Value,
label: &str,
) -> Option<victauri_core::AppEvent> {
use chrono::{TimeZone, Utc};
use victauri_core::AppEvent;
let event_type = ev.get("type").and_then(|t| t.as_str()).unwrap_or("");
#[allow(clippy::cast_possible_truncation)]
let now = ev
.get("timestamp")
.and_then(serde_json::Value::as_f64)
.filter(|ms| ms.is_finite() && *ms > 0.0)
.and_then(|ms| Utc.timestamp_millis_opt(ms as i64).single())
.unwrap_or_else(Utc::now);
let app_event = match event_type {
"console" => AppEvent::console(
ev.get("level")
.and_then(|l| l.as_str())
.unwrap_or("log")
.to_string(),
ev.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string(),
now,
),
"dom_mutation" => AppEvent::dom_mutation(
label.to_string(),
now,
ev.get("count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as u32,
),
"ipc" => {
let cmd = ev
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("unknown");
AppEvent::Ipc(
victauri_core::IpcCall::new(
uuid::Uuid::new_v4().to_string(),
cmd.to_string(),
now,
match ev.get("status").and_then(|s| s.as_str()) {
Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null),
Some("error") => victauri_core::IpcResult::Err("error".to_string()),
_ => victauri_core::IpcResult::Pending,
},
ev.get("duration_ms")
.and_then(serde_json::Value::as_f64)
.map(|d| d as u64),
ev.get("arg_size_bytes")
.and_then(serde_json::Value::as_u64)
.map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX)),
label.to_string(),
)
.with_mocked(ev.get("mocked").and_then(serde_json::Value::as_bool) == Some(true)),
)
}
"network" => AppEvent::state_change(
format!(
"network.{}",
ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET")
),
now,
ev.get("url")
.and_then(|u| u.as_str())
.map(std::string::ToString::to_string),
),
"navigation" => AppEvent::window_event(
label.to_string(),
format!(
"navigation.{}",
ev.get("nav_type")
.and_then(|n| n.as_str())
.unwrap_or("unknown")
),
now,
),
"dom_interaction" => {
let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click");
let action = match action_str {
"click" => victauri_core::InteractionKind::Click,
"double_click" => victauri_core::InteractionKind::DoubleClick,
"fill" => victauri_core::InteractionKind::Fill,
"key_press" => victauri_core::InteractionKind::KeyPress,
"select" => victauri_core::InteractionKind::Select,
"navigate" => victauri_core::InteractionKind::Navigate,
"scroll" => victauri_core::InteractionKind::Scroll,
_ => victauri_core::InteractionKind::Click,
};
AppEvent::dom_interaction(
action,
ev.get("selector")
.and_then(|s| s.as_str())
.unwrap_or("body")
.to_string(),
ev.get("value")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string),
now,
label.to_string(),
)
}
_ => return None,
};
Some(app_event)
}
async fn event_drain_loop(
state: Arc<VictauriState>,
bridge: Arc<dyn WebviewBridge>,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
loop {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
_ = shutdown.changed() => break,
}
if !state.recorder.is_recording() {
continue;
}
let labels = bridge.list_window_labels();
if labels.is_empty() {
continue;
}
state.drain_watermarks.retain(&labels);
let mut set = tokio::task::JoinSet::new();
for label in &labels {
let state = Arc::clone(&state);
let bridge = Arc::clone(&bridge);
let label = label.clone();
set.spawn(async move { drain_window_into_recording(&state, &bridge, &label).await });
}
while set.join_next().await.is_some() {}
}
}
pub(super) async fn drain_window_into_recording(
state: &Arc<VictauriState>,
bridge: &Arc<dyn WebviewBridge>,
label: &str,
) -> Option<usize> {
let lock = state.drain_watermarks.lock_for(label);
let _serialized = lock.lock().await;
let Some(generation) = state.recorder.generation() else {
return Some(0);
};
let cursor = state.drain_watermarks.cursor(label);
if cursor.epoch != generation {
return Some(0);
}
let (mark, recorded) = drain_window(state, bridge, label, &cursor, generation).await?;
state.drain_watermarks.advance(label, cursor.epoch, mark);
Some(recorded)
}
async fn drain_window(
state: &Arc<VictauriState>,
bridge: &Arc<dyn WebviewBridge>,
label: &str,
cursor: &crate::introspection::DrainCursor,
generation: u64,
) -> Option<(crate::introspection::DrainMark, usize)> {
let (after_seq, instance_js, floor_ms) = match &cursor.mark {
Some(m) => (m.seq, super::helpers::js_string(&m.instance), 0.0),
None => (0, "null".to_string(), cursor.floor_ms),
};
let id = uuid::Uuid::new_v4().to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
let _slot = {
let mut pending = state.pending_evals.lock().await;
if pending.len() >= MAX_PENDING_EVALS {
return None;
}
let slot = crate::PendingSlot::insert(&state.pending_evals, &mut pending, id.clone(), tx);
slot.bind_window(Some(label));
slot
};
let id_js = super::helpers::js_string(&id);
let inject = format!(
r"
(function() {{
var v = window.__VICTAURI__;
if (!v) return;
var r;
try {{ r = v.drainEvents({after_seq}, {instance_js}, {floor_ms}); }}
catch (e) {{ r = {{ __error: String(e && e.message) }}; }}
v._evalSettle({id_js}, r);
}})();
"
);
if bridge.eval_webview(Some(label), &inject).is_err() {
return None;
}
let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await else {
return None;
};
let reply: serde_json::Value = super::page_json::parse_page_json(&result).ok()?;
let mark = crate::introspection::DrainMark {
instance: reply.get("instance")?.as_str()?.to_string(),
seq: reply.get("seq")?.as_u64()?,
};
let mut recorded = 0usize;
for ev in reply.get("events")?.as_array()? {
if let Some(app_event) = parse_bridge_event_from(ev, label)
&& state
.recorder
.record_event_if(generation, app_event.clone())
{
state.event_log.push(app_event);
recorded += 1;
}
}
Some((mark, recorded))
}
#[cfg(test)]
mod tests {
use super::*;
use victauri_core::{AppEvent, InteractionKind, IpcResult};
#[cfg(unix)]
#[test]
fn uid_probe_survives_pre_planted_probe_names() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
for seq in 0..4096 {
std::fs::create_dir(dir.path().join(format!(
".victauri_plugin_uidprobe_{}_{seq}",
std::process::id()
)))
.unwrap();
}
let expected = std::fs::metadata(dir.path()).unwrap().uid();
assert_eq!(current_euid_in(dir.path()), Some(expected));
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 4096);
}
#[cfg(windows)]
#[test]
fn owner_only_dacl_removes_pre_planted_guests_ace() {
use std::process::Command;
let dir = std::env::temp_dir()
.join("victauri_acl_test")
.join(format!("p{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create test dir");
assert!(
dir_owned_by_current_user(&dir),
"a freshly created dir must be recognized as owned by this process"
);
let path_str = dir.to_string_lossy().to_string();
let Ok(grant) = Command::new("icacls")
.args([path_str.as_str(), "/grant", "*S-1-5-32-546:(OI)(CI)F", "/q"])
.output()
else {
let _ = std::fs::remove_dir_all(&dir);
return; };
if !grant.status.success() {
let _ = std::fs::remove_dir_all(&dir);
return; }
let before = Command::new("icacls")
.arg(path_str.as_str())
.output()
.expect("icacls read");
let before_s = String::from_utf8_lossy(&before.stdout);
assert!(
before_s.contains("Guests"),
"pre-condition: the planted Guests ACE should be visible, got:\n{before_s}"
);
assert!(
apply_owner_only_dacl(&dir),
"apply_owner_only_dacl must succeed on a directory we own"
);
let after = Command::new("icacls")
.arg(path_str.as_str())
.output()
.expect("icacls read");
let after_s = String::from_utf8_lossy(&after.stdout);
assert!(
!after_s.contains("Guests"),
"the pre-planted Guests ACE must NOT survive the owner-only DACL, got:\n{after_s}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn normalize_auth_token_collapses_empty() {
assert_eq!(normalize_auth_token(Some(String::new())), None);
assert_eq!(normalize_auth_token(Some(" ".to_string())), None);
assert_eq!(normalize_auth_token(Some("\t\n".to_string())), None);
assert_eq!(
normalize_auth_token(Some("secret-123".to_string())).as_deref(),
Some("secret-123")
);
assert_eq!(normalize_auth_token(None), None);
}
#[tokio::test]
async fn try_bind_preferred_port_available() {
let (listener, port) = try_bind(0).await.unwrap();
let addr = listener.local_addr().unwrap();
assert_eq!(port, 0);
assert_ne!(addr.port(), 0); }
#[tokio::test]
async fn try_bind_falls_back_when_taken() {
let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let blocked_port = blocker.local_addr().unwrap().port();
let (_, actual) = try_bind(blocked_port).await.unwrap();
assert_ne!(actual, blocked_port);
assert!(actual > blocked_port);
assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
}
#[test]
fn port_file_roundtrip() {
write_port_file(7777, Some("com.example.app"), Some("Example"));
let dir = discovery_dir();
let content = std::fs::read_to_string(dir.join("port")).unwrap();
assert_eq!(content, "7777");
let meta: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
.unwrap();
assert_eq!(meta["port"], 7777);
assert_eq!(meta["pid"], std::process::id());
assert_eq!(meta["identifier"], "com.example.app");
assert_eq!(meta["product_name"], "Example");
remove_port_file();
assert!(!dir.exists());
}
#[cfg(windows)]
#[test]
fn private_dir_restricts_shared_root_and_pid_dir() {
let base = std::env::temp_dir()
.join("victauri_private_root_test")
.join(format!("p{}", std::process::id()));
let dir = base.join("victauri").join("12345");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).expect("create parent test dir");
assert!(
ensure_private_dir(&dir),
"a fresh discovery root and pid dir owned by this user should be accepted"
);
assert!(
dir_owned_by_current_user(&base.join("victauri")),
"shared discovery root must be owned by this process user"
);
assert!(
dir_owned_by_current_user(&dir),
"pid discovery dir must be owned by this process user"
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn discovery_root_is_per_user() {
use std::os::unix::fs::PermissionsExt;
let euid = current_euid().expect("euid");
let shared = std::env::temp_dir().join("victauri");
let fallback = std::env::temp_dir().join(format!("victauri-{euid}"));
assert_ne!(discovery_root(), shared);
assert_eq!(discovery_root_from(None, Some(euid)), fallback);
let runtime = tempfile::tempdir().unwrap();
std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
discovery_root_from(Some(runtime.path().into()), Some(euid)),
runtime.path().join("victauri")
);
std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(
discovery_root_from(Some(runtime.path().into()), Some(euid)),
fallback
);
std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
discovery_root_from(Some(runtime.path().into()), Some(euid.wrapping_add(1))),
std::env::temp_dir().join(format!("victauri-{}", euid.wrapping_add(1)))
);
assert_eq!(
discovery_root_from(Some("relative/run".into()), Some(euid)),
fallback
);
}
#[cfg(unix)]
#[test]
fn an_untrusted_primary_root_falls_back_to_the_home_state_root() {
use std::os::unix::fs::PermissionsExt;
let base = tempfile::tempdir().unwrap();
let home = base.path().join("home");
std::fs::create_dir(&home).unwrap();
std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap();
let fallback = home_state_root(None, Some(home.clone().into()));
let expected = home.join(".local").join("state").join("victauri");
assert_eq!(fallback.as_deref(), Some(expected.as_path()));
let elsewhere = base.path().join("elsewhere");
std::fs::create_dir(&elsewhere).unwrap();
let hijacked = base.path().join("victauri-hijacked");
std::os::unix::fs::symlink(&elsewhere, &hijacked).unwrap();
assert_eq!(select_discovery_root(&hijacked, fallback.clone()), expected);
assert!(unix_private_dir_is_trusted(&expected));
let mode = std::fs::metadata(&expected).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
let blocked = base.path().join("victauri-blocked");
std::fs::write(&blocked, "x").unwrap();
assert_eq!(select_discovery_root(&blocked, fallback.clone()), expected);
let fine = base.path().join("victauri-fine");
assert_eq!(select_discovery_root(&fine, fallback), fine);
assert_eq!(select_discovery_root(&hijacked, None), hijacked);
}
#[cfg(unix)]
#[test]
fn home_state_root_prefers_an_absolute_xdg_state_home_and_guards_its_parent() {
use std::os::unix::fs::PermissionsExt;
assert_eq!(
home_state_root(Some("/x/state".into()), Some("/home/u".into())),
Some(std::path::PathBuf::from("/x/state/victauri"))
);
assert_eq!(
home_state_root(Some("rel/state".into()), Some("/home/u".into())),
Some(std::path::PathBuf::from("/home/u/.local/state/victauri"))
);
assert_eq!(home_state_root(None, Some("rel".into())), None);
assert_eq!(home_state_root(None, None), None);
let base = tempfile::tempdir().unwrap();
let open = base.path().join("open");
std::fs::create_dir(&open).unwrap();
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(!ensure_home_state_root(&open.join("victauri")));
}
#[cfg(unix)]
#[test]
fn private_dir_refuses_symlink_without_chmodding_target() {
use std::os::unix::fs::PermissionsExt;
let base = tempfile::tempdir().unwrap();
let target = base.path().join("target");
let link = base.path().join("link");
std::fs::create_dir(&target).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(!ensure_unix_private_dir(&link));
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "symlink target permissions must be untouched");
}
#[test]
fn parse_dom_interaction_click() {
let ev = serde_json::json!({
"type": "dom_interaction",
"action": "click",
"selector": "#submit-btn",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::DomInteraction {
action,
selector,
value,
webview_label,
..
} => {
assert_eq!(action, InteractionKind::Click);
assert_eq!(selector, "#submit-btn");
assert!(value.is_none());
assert_eq!(webview_label, "main");
}
other => panic!("expected DomInteraction, got {other:?}"),
}
}
#[test]
fn parse_dom_interaction_fill_with_value() {
let ev = serde_json::json!({
"type": "dom_interaction",
"action": "fill",
"selector": "input[name=email]",
"value": "test@example.com",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::DomInteraction {
action,
selector,
value,
..
} => {
assert_eq!(action, InteractionKind::Fill);
assert_eq!(selector, "input[name=email]");
assert_eq!(value.as_deref(), Some("test@example.com"));
}
other => panic!("expected DomInteraction, got {other:?}"),
}
}
#[test]
fn parse_dom_interaction_key_press() {
let ev = serde_json::json!({
"type": "dom_interaction",
"action": "key_press",
"selector": "body",
"value": "Enter",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::DomInteraction { action, value, .. } => {
assert_eq!(action, InteractionKind::KeyPress);
assert_eq!(value.as_deref(), Some("Enter"));
}
other => panic!("expected DomInteraction, got {other:?}"),
}
}
#[test]
fn parse_dom_interaction_unknown_action_defaults_to_click() {
let ev = serde_json::json!({
"type": "dom_interaction",
"action": "swipe_left",
"selector": ".card",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::DomInteraction { action, .. } => {
assert_eq!(action, InteractionKind::Click);
}
other => panic!("expected DomInteraction, got {other:?}"),
}
}
#[test]
fn parse_dom_interaction_missing_action_defaults_to_click() {
let ev = serde_json::json!({
"type": "dom_interaction",
"selector": "button",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::DomInteraction { action, .. } => {
assert_eq!(action, InteractionKind::Click);
}
other => panic!("expected DomInteraction, got {other:?}"),
}
}
#[test]
fn parse_dom_interaction_missing_selector_defaults_to_body() {
let ev = serde_json::json!({
"type": "dom_interaction",
"action": "scroll",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::DomInteraction {
action, selector, ..
} => {
assert_eq!(action, InteractionKind::Scroll);
assert_eq!(selector, "body");
}
other => panic!("expected DomInteraction, got {other:?}"),
}
}
#[test]
fn parse_dom_interaction_all_action_kinds() {
let cases = [
("click", InteractionKind::Click),
("double_click", InteractionKind::DoubleClick),
("fill", InteractionKind::Fill),
("key_press", InteractionKind::KeyPress),
("select", InteractionKind::Select),
("navigate", InteractionKind::Navigate),
("scroll", InteractionKind::Scroll),
];
for (action_str, expected_kind) in cases {
let ev = serde_json::json!({
"type": "dom_interaction",
"action": action_str,
"selector": "body",
});
let result = parse_bridge_event(&ev)
.unwrap_or_else(|| panic!("should produce event for action {action_str}"));
match result {
AppEvent::DomInteraction { action, .. } => {
assert_eq!(action, expected_kind, "mismatch for action {action_str}");
}
other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
}
}
}
#[test]
fn parse_ipc_status_ok() {
let ev = serde_json::json!({
"type": "ipc",
"command": "greet",
"status": "ok",
"duration_ms": 42.0,
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::Ipc(call) => {
assert_eq!(call.command, "greet");
assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
assert_eq!(call.duration_ms, Some(42));
assert_eq!(call.webview_label, "main");
}
other => panic!("expected Ipc, got {other:?}"),
}
}
#[test]
fn parse_ipc_status_error() {
let ev = serde_json::json!({
"type": "ipc",
"command": "save_file",
"status": "error",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::Ipc(call) => {
assert_eq!(call.command, "save_file");
assert_eq!(call.result, IpcResult::Err("error".to_string()));
}
other => panic!("expected Ipc, got {other:?}"),
}
}
#[test]
fn parse_ipc_status_pending() {
let ev = serde_json::json!({
"type": "ipc",
"command": "long_task",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::Ipc(call) => {
assert_eq!(call.result, IpcResult::Pending);
assert!(call.duration_ms.is_none());
}
other => panic!("expected Ipc, got {other:?}"),
}
}
#[test]
fn parse_console_event() {
let ev = serde_json::json!({
"type": "console",
"level": "warn",
"message": "deprecated API usage",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::Console { level, message, .. } => {
assert_eq!(level, "warn");
assert_eq!(message, "deprecated API usage");
}
other => panic!("expected Console, got {other:?}"),
}
}
#[test]
fn parse_console_default_level() {
let ev = serde_json::json!({
"type": "console",
"message": "hello",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::Console { level, message, .. } => {
assert_eq!(level, "log");
assert_eq!(message, "hello");
}
other => panic!("expected Console, got {other:?}"),
}
}
#[test]
fn parse_navigation_event() {
let ev = serde_json::json!({
"type": "navigation",
"nav_type": "push",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::WindowEvent { label, event, .. } => {
assert_eq!(label, "main");
assert_eq!(event, "navigation.push");
}
other => panic!("expected WindowEvent, got {other:?}"),
}
}
#[test]
fn parse_navigation_default_nav_type() {
let ev = serde_json::json!({ "type": "navigation" });
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::WindowEvent { event, .. } => {
assert_eq!(event, "navigation.unknown");
}
other => panic!("expected WindowEvent, got {other:?}"),
}
}
#[test]
fn parse_dom_mutation_event() {
let ev = serde_json::json!({
"type": "dom_mutation",
"count": 15,
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::DomMutation {
webview_label,
mutation_count,
..
} => {
assert_eq!(webview_label, "main");
assert_eq!(mutation_count, 15);
}
other => panic!("expected DomMutation, got {other:?}"),
}
}
#[test]
fn parse_network_event() {
let ev = serde_json::json!({
"type": "network",
"method": "POST",
"url": "https://api.example.com/data",
});
let result = parse_bridge_event(&ev).expect("should produce an event");
match result {
AppEvent::StateChange { key, caused_by, .. } => {
assert_eq!(key, "network.POST");
assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
}
other => panic!("expected StateChange, got {other:?}"),
}
}
#[test]
fn parse_unknown_type_returns_none() {
let ev = serde_json::json!({
"type": "custom_telemetry",
"payload": 42,
});
assert!(parse_bridge_event(&ev).is_none());
}
#[test]
fn parse_missing_type_field_returns_none() {
let ev = serde_json::json!({ "data": "no type here" });
assert!(parse_bridge_event(&ev).is_none());
}
#[test]
fn parse_empty_object_returns_none() {
let ev = serde_json::json!({});
assert!(parse_bridge_event(&ev).is_none());
}
}