pub mod diff;
mod error;
#[cfg(any(unix, windows))]
pub(crate) mod eval;
#[cfg(any(unix, windows))]
mod handler;
#[cfg(feature = "press")]
pub(crate) mod key;
pub(crate) mod protocol;
pub(crate) mod recorder;
pub(crate) mod screenshot;
#[cfg(any(unix, windows))]
pub(crate) mod server;
pub use error::Error;
#[cfg(any(unix, windows))]
use eval::EvalEngine;
#[cfg(any(unix, windows))]
use recorder::Recorder;
#[cfg(any(unix, windows))]
use server::{EvalFn, FocusFn, ListWindowsFn};
#[cfg(any(unix, windows))]
use std::sync::Arc;
#[cfg(any(unix, windows))]
use tauri::Manager;
#[cfg(all(any(unix, windows), debug_assertions))]
pub(crate) const BRIDGE_JS: &str =
concat!(include_str!("../js/vendor/html-to-image.iife.js"), "\n", include_str!("../js/bridge.js"));
#[must_use]
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
#[cfg(not(all(any(unix, windows), debug_assertions)))]
{
return tauri::plugin::Builder::new("hasgard").build();
}
#[cfg(all(any(unix, windows), debug_assertions))]
{
tauri::plugin::Builder::new("hasgard")
.js_init_script(BRIDGE_JS.to_owned())
.setup(|app, _api| {
let engine = EvalEngine::new();
app.manage(engine.clone());
let identifier = sanitize_identifier(&app.config().identifier);
let socket_path = match std::env::var_os("TAURI_HASGARD_SOCKET") {
Some(path) => std::path::PathBuf::from(path),
None => server::socket_path(&identifier),
};
let eval_fn = make_eval_fn(app);
let list_fn = make_list_fn(app);
let focus_fn = make_focus_fn(app);
let recorder = Recorder::new();
#[cfg(unix)]
{
let (listener, guard) = server::bind(&socket_path).map_err(|e| {
tracing::error!(path = %socket_path.display(), "failed to bind socket: {e}");
e
})?;
tauri::async_runtime::spawn(server::run(
listener,
guard,
engine,
Some(eval_fn),
Some(list_fn),
Some(focus_fn),
recorder,
));
}
#[cfg(windows)]
tauri::async_runtime::spawn(server::run(
socket_path,
engine,
Some(eval_fn),
Some(list_fn),
Some(focus_fn),
recorder,
));
Ok(())
})
.invoke_handler(tauri::generate_handler![handler::callback, handler::__callback])
.build()
}
}
#[cfg(all(any(unix, windows), debug_assertions))]
fn sanitize_identifier(raw: &str) -> String {
let sanitized: String = raw
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
if sanitized.is_empty() { "default".to_owned() } else { sanitized }
}
#[cfg(all(any(unix, windows), debug_assertions))]
fn make_eval_fn<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> EvalFn {
let handle = app.clone();
Arc::new(move |window: Option<&str>, script: String| {
let target = if let Some(label) = window {
handle.get_webview_window(label).ok_or_else(|| format!("Window '{label}' not found"))?
} else {
handle.get_webview_window("main").ok_or_else(|| "Window 'main' not found".to_owned())?
};
target.eval(&script).map_err(|e| e.to_string())
})
}
#[cfg(all(any(unix, windows), debug_assertions))]
fn make_focus_fn<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> FocusFn {
let handle = app.clone();
Arc::new(move |window: Option<&str>| {
let target = if let Some(label) = window {
handle.get_webview_window(label).ok_or_else(|| format!("Window '{label}' not found"))?
} else {
handle.get_webview_window("main").ok_or_else(|| "Window 'main' not found".to_owned())?
};
target.set_focus().map_err(|e| e.to_string())?;
#[cfg(windows)]
{
use std::sync::mpsc;
use std::time::Duration;
use webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC;
let (sender, receiver) = mpsc::sync_channel(1);
target
.with_webview(move |webview| {
let result = unsafe { webview.controller().MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC) }
.map_err(|error| error.to_string());
sender.send(result).expect("focus result receiver must exist");
})
.map_err(|error| error.to_string())?;
receiver
.recv_timeout(Duration::from_secs(2))
.map_err(|error| format!("WebView focus timed out: {error}"))??;
}
Ok(())
})
}
#[cfg(all(any(unix, windows), debug_assertions))]
fn make_list_fn<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> ListWindowsFn {
let handle = app.clone();
Arc::new(move || {
let windows = handle.webview_windows();
let list: Result<Vec<serde_json::Value>, String> = windows
.iter()
.map(|(label, wv)| {
let url = wv.url().map_err(|error| format!("Failed to read URL for window '{label}': {error}"))?;
let title =
wv.title().map_err(|error| format!("Failed to read title for window '{label}': {error}"))?;
Ok(serde_json::json!({
"label": label,
"url": url.to_string(),
"title": title,
}))
})
.collect();
Ok(serde_json::json!({"windows": list?}))
})
}
#[cfg(test)]
mod tests {
#[cfg(all(any(unix, windows), debug_assertions))]
#[test]
fn bridge_js_contains_html_to_image_and_hasgard() {
let js = super::BRIDGE_JS;
assert!(js.contains("htmlToImage"), "BRIDGE_JS must include the html-to-image IIFE bundle");
assert!(js.contains("window.__HASGARD__"), "BRIDGE_JS must include the hasgard bridge");
let html_idx = js.find("htmlToImage").expect("htmlToImage missing");
let hasgard_idx = js.find("window.__HASGARD__").expect("window.__HASGARD__ missing");
assert!(html_idx < hasgard_idx, "html-to-image must be injected before hasgard bridge code");
}
#[cfg(all(any(unix, windows), debug_assertions))]
#[test]
fn bridge_click_dispatches_pointer_sequence() {
let js = super::BRIDGE_JS;
let js_normalized: String = js.lines().collect::<Vec<_>>().join("\n");
let pointer_down_idx = js
.find(r#"dispatchPointerEvent(el, "pointerdown""#)
.expect("click must dispatch pointerdown for Radix triggers");
let mouse_down_idx = js.find(r#"MouseEvent("mousedown""#).expect("click must keep mousedown compatibility");
let pointer_up_idx = js
.find(r#"dispatchPointerEvent(el, "pointerup""#)
.expect("click must dispatch pointerup for Radix triggers");
let mouse_up_idx = js.find(r#"MouseEvent("mouseup""#).expect("click must keep mouseup compatibility");
let click_idx = js.find(r#"dispatchPointerEvent(el, "click""#).expect("click must dispatch as a pointer event");
assert!(
pointer_down_idx < mouse_down_idx
&& mouse_down_idx < pointer_up_idx
&& pointer_up_idx < mouse_up_idx
&& mouse_up_idx < click_idx,
"click must dispatch pointerdown -> mousedown -> pointerup -> mouseup -> click"
);
assert!(js.contains(r#"pointerType: "mouse""#), "pointer events must include mouse pointer metadata");
assert!(
js_normalized.contains(
"if (pointerDownOk) {\n const mouseDownOk = el.dispatchEvent(new MouseEvent(\"mousedown\""
),
"mousedown must only dispatch when pointerdown was not canceled"
);
assert!(
js_normalized.contains("if (pointerDownOk) {\n el.dispatchEvent(new MouseEvent(\"mouseup\""),
"mouseup must only dispatch when pointerdown was not canceled"
);
}
#[cfg(all(any(unix, windows), debug_assertions))]
#[test]
fn bridge_scroll_handles_top_and_bottom_directions() {
let js = super::BRIDGE_JS;
assert!(js.contains(r#"if (dir === "top")"#), "scroll must handle direction \"top\"");
assert!(js.contains(r#"if (dir === "bottom")"#), "scroll must handle direction \"bottom\"");
assert!(
js.contains("target.scrollTo(window.scrollX, 0)"),
"scroll top on window must preserve window.scrollX and set Y=0"
);
assert!(
js.contains("target.scrollTo(window.scrollX, Math.max(0, max))"),
"scroll bottom on window must preserve window.scrollX and clamp negative max"
);
assert!(
js.contains("Math.max(")
&& js.contains("docEl ? docEl.scrollHeight : 0")
&& js.contains("body ? body.scrollHeight : 0"),
"scroll bottom on window must use Math.max(documentElement.scrollHeight, body.scrollHeight) for quirks-mode safety"
);
assert!(
js.contains("docEl ? docEl.clientHeight : window.innerHeight"),
"scroll bottom on window must subtract docEl.clientHeight (excludes horizontal scrollbar) instead of window.innerHeight"
);
assert!(
js.contains("String(dir).slice(0, 64)"),
"scroll error message must cap user-supplied direction length"
);
assert!(js.contains("target.scrollTop = 0"), "scroll top on element must set scrollTop = 0");
assert!(
js.contains("target.scrollTop = Math.max(0, target.scrollHeight - target.clientHeight)"),
"scroll bottom on element must use scrollHeight - clientHeight (not raw scrollHeight)"
);
assert!(
js.contains("Unknown scroll direction:"),
"scroll must throw on unknown direction instead of silently no-op"
);
}
#[cfg(all(any(unix, windows), debug_assertions))]
#[test]
fn bridge_eval_auto_wraps_top_level_await() {
let js = super::BRIDGE_JS;
assert!(js.contains("function evalScript("), "BRIDGE_JS must define evalScript");
assert!(
js.contains("(async () => (\\n\" + script + \"\\n))()"),
"evalScript must include the async-expression compile stage (#79)"
);
assert!(
js.contains("hasTopLevelAwait(script)"),
"evalScript must guard the async fallbacks with hasTopLevelAwait (#79)"
);
assert!(
js.contains("(async () => {\\n\" + script + \"\\n})()"),
"evalScript must include the async-statement IIFE fallback (#79)"
);
assert!(js.contains("function hasTopLevelAwait("), "BRIDGE_JS must define the hasTopLevelAwait helper (#79)");
assert!(
js.contains("top-level await detected but the script could not be auto-wrapped"),
"evalScript must surface a clear error when auto-wrap fails (#79)"
);
let evalscript_idx = js.find("function evalScript(").expect("evalScript missing");
let body = &js[evalscript_idx..];
let expr_idx = body.find("\"return (\\n\" + script + \"\\n)\"").expect("stage 1 expression compile missing");
let async_expr_idx = body
.find("\"return (async () => (\\n\" + script + \"\\n))()\"")
.expect("stage 2 async-expression compile missing");
let async_stmt_idx = body
.find("\"return (async () => {\\n\" + script + \"\\n})()\"")
.expect("stage 3 async-statement IIFE missing");
let indirect_idx = body.find("var indirectEval = eval;").expect("indirect eval fallback missing");
assert!(expr_idx < async_expr_idx, "expression compile must precede async-expression fallback");
assert!(async_expr_idx < async_stmt_idx, "async-expression must precede async-statement fallback");
assert!(
async_stmt_idx < indirect_idx,
"async-statement IIFE must precede plain indirect eval (await guard runs first)"
);
}
#[cfg(all(any(unix, windows), debug_assertions))]
#[test]
fn bridge_native_value_setter_picks_prototype_per_element() {
let js = super::BRIDGE_JS;
assert!(
js.contains("function nativeValueSetter("),
"BRIDGE_JS must define a nativeValueSetter helper that picks the prototype based on the element (#85)"
);
assert!(
js.contains("Object.getPrototypeOf(el)"),
"nativeValueSetter must derive the prototype from the element instance (#85)"
);
let buggy_pattern = "Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, \"value\") ||";
assert!(
!js.contains(buggy_pattern),
"fill/typeText must not use the `HTMLInputElement.prototype || HTMLTextAreaElement.prototype` short-circuit (#85)"
);
let body_of = |fn_decl: &str| -> &str {
let start = js.find(fn_decl).unwrap_or_else(|| panic!("{fn_decl} missing"));
let after = start + fn_decl.len();
let end = js[after..].find("\n function ").map_or(js.len(), |off| after + off);
&js[start..end]
};
let fill_body = body_of("function fill(params)");
let type_body = body_of("function typeText(params)");
let select_body = body_of("function select(params)");
assert!(fill_body.contains("nativeValueSetter("), "fill must call nativeValueSetter (#85)");
assert!(type_body.contains("nativeValueSetter("), "typeText must call nativeValueSetter (#85)");
assert!(
select_body.contains("nativeValueSetter("),
"select must call nativeValueSetter (#85) so a future textarea-style brand-check bug cannot reappear in any setter handler"
);
assert!(
select_body.contains("select requires a <select> element"),
"select must explicitly reject non-<select> targets after the nativeValueSetter refactor (#85)"
);
assert!(
!select_body.contains("instanceof HTMLSelectElement"),
"select guard must be realm-safe — `instanceof HTMLSelectElement` rejects valid <select> elements from another realm, which contradicts the cross-realm support that motivated nativeValueSetter (#85)"
);
let fill_idx = js.find("function fill(params)").expect("fill function missing");
let helper_idx = js.find("function nativeValueSetter(").expect("nativeValueSetter helper missing");
assert!(helper_idx < fill_idx, "nativeValueSetter must be declared before fill (#85)");
}
#[cfg(all(any(unix, windows), debug_assertions))]
#[test]
fn bridge_role_map_maps_paragraph_and_keeps_it_noninteractive() {
let js = super::BRIDGE_JS;
assert!(
js.contains("P: \"paragraph\""),
"ROLE_MAP must map P to \"paragraph\" so snapshot includes <p> text (#109)"
);
let set_start = js.find("INTERACTIVE_ROLES = new Set([").expect("INTERACTIVE_ROLES set missing");
let set_body = &js[set_start..];
let set_end = set_body.find("]);").expect("INTERACTIVE_ROLES set unterminated");
assert!(
!set_body[..set_end].contains("\"paragraph\""),
"paragraph must stay out of INTERACTIVE_ROLES so interactive snapshots still exclude <p> (#109)"
);
}
}