1pub mod diff;
2mod error;
3#[cfg(any(unix, windows))]
4pub(crate) mod eval;
5#[cfg(any(unix, windows))]
6mod handler;
7#[cfg(feature = "press")]
8pub(crate) mod key;
9pub(crate) mod protocol;
10pub(crate) mod recorder;
11pub(crate) mod screenshot;
14#[cfg(any(unix, windows))]
15pub(crate) mod server;
16
17pub use error::Error;
18
19#[cfg(any(unix, windows))]
20use eval::EvalEngine;
21#[cfg(any(unix, windows))]
22use recorder::Recorder;
23#[cfg(any(unix, windows))]
24use server::{EvalFn, ListWindowsFn, PressHooksRef};
25#[cfg(any(unix, windows))]
26use std::sync::Arc;
27#[cfg(any(unix, windows))]
28use tauri::Manager;
29
30#[cfg(all(any(unix, windows), debug_assertions))]
31pub(crate) const BRIDGE_JS: &str =
32 concat!(include_str!("../js/vendor/html-to-image.iife.js"), "\n", include_str!("../js/bridge.js"));
33
34#[must_use]
43pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
44 #[cfg(not(all(any(unix, windows), debug_assertions)))]
45 {
46 return tauri::plugin::Builder::new("hasgard").build();
47 }
48
49 #[cfg(all(any(unix, windows), debug_assertions))]
50 {
51 tauri::plugin::Builder::new("hasgard")
52 .js_init_script(BRIDGE_JS.to_owned())
53 .setup(|app, _api| {
54 let engine = EvalEngine::new();
55 app.manage(engine.clone());
56
57 let identifier = sanitize_identifier(&app.config().identifier);
58 let socket_path = match std::env::var_os("TAURI_HASGARD_SOCKET") {
59 Some(path) => std::path::PathBuf::from(path),
60 None => server::socket_path(&identifier),
61 };
62
63 let eval_fn = make_eval_fn(app);
64 let list_fn = make_list_fn(app);
65 let press_hooks = make_press_hooks(app);
66
67 let recorder = Recorder::new();
68
69 #[cfg(unix)]
74 {
75 let (listener, guard) = server::bind(&socket_path).map_err(|e| {
76 tracing::error!(path = %socket_path.display(), "failed to bind socket: {e}");
77 e
78 })?;
79 tauri::async_runtime::spawn(server::run(
80 listener,
81 guard,
82 engine,
83 Some(eval_fn),
84 Some(list_fn),
85 Some(press_hooks),
86 recorder,
87 ));
88 }
89
90 #[cfg(windows)]
96 tauri::async_runtime::spawn(server::run(
97 socket_path,
98 engine,
99 Some(eval_fn),
100 Some(list_fn),
101 Some(press_hooks),
102 recorder,
103 ));
104
105 Ok(())
106 })
107 .invoke_handler(tauri::generate_handler![handler::callback, handler::__callback])
108 .build()
109 }
110}
111
112#[cfg(all(any(unix, windows), debug_assertions))]
115fn sanitize_identifier(raw: &str) -> String {
116 let sanitized: String = raw
117 .chars()
118 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
119 .collect();
120 if sanitized.is_empty() { "default".to_owned() } else { sanitized }
121}
122
123#[cfg(all(any(unix, windows), debug_assertions))]
128fn make_eval_fn<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> EvalFn {
129 let handle = app.clone();
130 Arc::new(move |window: Option<&str>, script: String| {
131 let target = if let Some(label) = window {
132 handle.get_webview_window(label).ok_or_else(|| format!("Window '{label}' not found"))?
133 } else {
134 handle.get_webview_window("main").ok_or_else(|| "Window 'main' not found".to_owned())?
135 };
136 target.eval(&script).map_err(|e| e.to_string())
140 })
141}
142
143#[cfg(all(any(unix, windows), debug_assertions))]
148fn make_press_hooks<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> PressHooksRef {
149 let focus_handle = app.clone();
150 #[cfg(target_os = "macos")]
153 let main_handle = app.clone();
154 Arc::new(crate::server::PressHooks {
155 focus: Box::new(move |window: Option<&str>| {
156 let target = if let Some(label) = window {
157 focus_handle.get_webview_window(label).ok_or_else(|| format!("Window '{label}' not found"))?
158 } else {
159 focus_handle.get_webview_window("main").ok_or_else(|| "Window 'main' not found".to_owned())?
160 };
161 target.set_focus().map_err(|e| e.to_string())?;
162
163 #[cfg(windows)]
164 {
165 use std::sync::mpsc;
166 use webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC;
167
168 let (sender, receiver) = mpsc::sync_channel(1);
169 target
170 .with_webview(move |webview| {
171 let result =
172 unsafe { webview.controller().MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC) }
173 .map_err(|error| error.to_string());
174 sender.send(result).expect("focus result receiver must exist");
175 })
176 .map_err(|error| error.to_string())?;
177 receiver
178 .recv_timeout(std::time::Duration::from_secs(2))
179 .map_err(|error| format!("WebView focus timed out: {error}"))??;
180 }
181
182 Ok(())
183 }),
184 #[cfg(target_os = "macos")]
189 run_injection: Box::new(move |task: Box<dyn FnOnce() + Send>| {
190 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
191 main_handle
192 .run_on_main_thread(move || {
193 task();
194 let _ = done_tx.send(());
197 })
198 .map_err(|error| format!("could not reach the main thread: {error}"))?;
199 done_rx
200 .recv_timeout(std::time::Duration::from_secs(10))
201 .map_err(|error| format!("main-thread task did not finish: {error}"))
202 }),
203 #[cfg(not(target_os = "macos"))]
209 run_injection: Box::new(|task: Box<dyn FnOnce() + Send>| {
210 task();
211 Ok(())
212 }),
213 })
214}
215
216#[cfg(all(target_os = "macos", debug_assertions))]
236fn native_window_id<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> Option<u32> {
237 use objc2_app_kit::NSWindow;
238
239 let target = window.clone();
240 let (id_tx, id_rx) = std::sync::mpsc::sync_channel(1);
241 window
242 .run_on_main_thread(move || {
243 let id = target.ns_window().ok().filter(|ptr| !ptr.is_null()).and_then(|ptr| {
244 let ns_window: &NSWindow = unsafe { &*ptr.cast::<NSWindow>() };
250 u32::try_from(ns_window.windowNumber()).ok()
251 });
252 let _ = id_tx.send(id);
255 })
256 .ok()?;
257 id_rx.recv_timeout(std::time::Duration::from_secs(2)).ok().flatten()
260}
261
262#[cfg(all(any(unix, windows), debug_assertions, not(target_os = "macos")))]
263fn native_window_id<R: tauri::Runtime>(_window: &tauri::WebviewWindow<R>) -> Option<u32> {
264 None
265}
266
267#[cfg(all(any(unix, windows), debug_assertions))]
269fn make_list_fn<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> ListWindowsFn {
270 let handle = app.clone();
271 Arc::new(move || {
272 let windows = handle.webview_windows();
273 let list: Result<Vec<serde_json::Value>, String> = windows
275 .iter()
276 .map(|(label, wv)| {
277 let url = wv.url().map_err(|error| format!("Failed to read URL for window '{label}': {error}"))?;
278 let title =
279 wv.title().map_err(|error| format!("Failed to read title for window '{label}': {error}"))?;
280 let mut entry = serde_json::json!({
281 "label": label,
282 "url": url.to_string(),
283 "title": title,
284 });
285 if let Some(native_id) = native_window_id(wv) {
289 entry["native_id"] = serde_json::json!(native_id);
290 }
291 Ok(entry)
292 })
293 .collect();
294 Ok(serde_json::json!({"windows": list?}))
295 })
296}
297
298#[cfg(test)]
299mod tests {
300 #[cfg(all(any(unix, windows), debug_assertions))]
301 #[test]
302 fn bridge_js_contains_html_to_image_and_hasgard() {
303 let js = super::BRIDGE_JS;
304 assert!(js.contains("htmlToImage"), "BRIDGE_JS must include the html-to-image IIFE bundle");
305 assert!(js.contains("window.__HASGARD__"), "BRIDGE_JS must include the hasgard bridge");
306 let html_idx = js.find("htmlToImage").expect("htmlToImage missing");
307 let hasgard_idx = js.find("window.__HASGARD__").expect("window.__HASGARD__ missing");
308 assert!(html_idx < hasgard_idx, "html-to-image must be injected before hasgard bridge code");
309 }
310
311 #[cfg(all(any(unix, windows), debug_assertions))]
323 #[test]
324 fn bridge_click_dispatches_pointer_sequence() {
325 let js = super::BRIDGE_JS;
326 let scroll_idx = js
327 .find(r#"el.scrollIntoView({ behavior: "instant", block: "center", inline: "center" })"#)
328 .expect("click must scroll the target into view");
329 let pointer_down_idx = js
330 .find(r#"dispatchPointerEvent(el, "pointerdown""#)
331 .expect("click must dispatch pointerdown for Radix triggers");
332 let mouse_down_idx = js.find(r#"MouseEvent("mousedown""#).expect("click must keep mousedown compatibility");
333 let pointer_up_idx = js
334 .find(r#"dispatchPointerEvent(el, "pointerup""#)
335 .expect("click must dispatch pointerup for Radix triggers");
336 let mouse_up_idx = js.find(r#"MouseEvent("mouseup""#).expect("click must keep mouseup compatibility");
337 let click_idx = js.find(r#"dispatchPointerEvent(el, "click""#).expect("click must dispatch as a pointer event");
338
339 assert!(
340 scroll_idx < pointer_down_idx
341 && pointer_down_idx < mouse_down_idx
342 && mouse_down_idx < pointer_up_idx
343 && pointer_up_idx < mouse_up_idx
344 && mouse_up_idx < click_idx,
345 "click must dispatch pointerdown -> mousedown -> pointerup -> mouseup -> click"
346 );
347 assert!(js.contains(r#"pointerType: "mouse""#), "pointer events must include mouse pointer metadata");
348 assert!(js.contains("const rect = el.getBoundingClientRect()"), "click must measure the target");
349
350 let gated = js.matches("if (pointerDownOk) {").count();
353 assert!(gated >= 2, "mousedown and mouseup must each sit behind a pointerDownOk gate, found {gated}");
354 }
355
356 #[cfg(all(any(unix, windows), debug_assertions))]
357 #[test]
358 fn bridge_scroll_handles_top_and_bottom_directions() {
359 let js = super::BRIDGE_JS;
360 assert!(js.contains(r#"if (dir === "top")"#), "scroll must handle direction \"top\"");
361 assert!(js.contains(r#"if (dir === "bottom")"#), "scroll must handle direction \"bottom\"");
362 assert!(
363 js.contains("target.scrollTo(window.scrollX, 0)"),
364 "scroll top on window must preserve window.scrollX and set Y=0"
365 );
366 assert!(
367 js.contains("target.scrollTo(window.scrollX, Math.max(0, max))"),
368 "scroll bottom on window must preserve window.scrollX and clamp negative max"
369 );
370 assert!(
371 js.contains("Math.max(")
372 && js.contains("docEl ? docEl.scrollHeight : 0")
373 && js.contains("body ? body.scrollHeight : 0"),
374 "scroll bottom on window must use Math.max(documentElement.scrollHeight, body.scrollHeight) for quirks-mode safety"
375 );
376 assert!(
377 js.contains("docEl ? docEl.clientHeight : window.innerHeight"),
378 "scroll bottom on window must subtract docEl.clientHeight (excludes horizontal scrollbar) instead of window.innerHeight"
379 );
380 assert!(
381 js.contains("String(dir).slice(0, 64)"),
382 "scroll error message must cap user-supplied direction length"
383 );
384 assert!(js.contains("target.scrollTop = 0"), "scroll top on element must set scrollTop = 0");
385 assert!(
386 js.contains("target.scrollTop = Math.max(0, target.scrollHeight - target.clientHeight)"),
387 "scroll bottom on element must use scrollHeight - clientHeight (not raw scrollHeight)"
388 );
389 assert!(
390 js.contains("Unknown scroll direction:"),
391 "scroll must throw on unknown direction instead of silently no-op"
392 );
393 }
394
395 #[cfg(all(any(unix, windows), debug_assertions))]
396 #[test]
397 fn bridge_eval_auto_wraps_top_level_await() {
398 let js = super::BRIDGE_JS;
402 assert!(js.contains("function evalScript("), "BRIDGE_JS must define evalScript");
403 assert!(
404 js.contains("(async () => (\\n\" + script + \"\\n))()"),
405 "evalScript must include the async-expression compile stage (#79)"
406 );
407 assert!(
408 js.contains("hasTopLevelAwait(script)"),
409 "evalScript must guard the async fallbacks with hasTopLevelAwait (#79)"
410 );
411 assert!(
412 js.contains("(async () => {\\n\" + script + \"\\n})()"),
413 "evalScript must include the async-statement IIFE fallback (#79)"
414 );
415 assert!(js.contains("function hasTopLevelAwait("), "BRIDGE_JS must define the hasTopLevelAwait helper (#79)");
416 assert!(
417 js.contains("top-level await detected but the script could not be auto-wrapped"),
418 "evalScript must surface a clear error when auto-wrap fails (#79)"
419 );
420
421 let evalscript_idx = js.find("function evalScript(").expect("evalScript missing");
427 let body = &js[evalscript_idx..];
429 let expr_idx = body.find("\"return (\\n\" + script + \"\\n)\"").expect("stage 1 expression compile missing");
430 let async_expr_idx = body
431 .find("\"return (async () => (\\n\" + script + \"\\n))()\"")
432 .expect("stage 2 async-expression compile missing");
433 let async_stmt_idx = body
434 .find("\"return (async () => {\\n\" + script + \"\\n})()\"")
435 .expect("stage 3 async-statement IIFE missing");
436 let indirect_idx = body.find("var indirectEval = eval;").expect("indirect eval fallback missing");
437 assert!(expr_idx < async_expr_idx, "expression compile must precede async-expression fallback");
438 assert!(async_expr_idx < async_stmt_idx, "async-expression must precede async-statement fallback");
439 assert!(
440 async_stmt_idx < indirect_idx,
441 "async-statement IIFE must precede plain indirect eval (await guard runs first)"
442 );
443 }
444
445 #[cfg(all(any(unix, windows), debug_assertions))]
446 #[test]
447 fn bridge_native_value_setter_picks_prototype_per_element() {
448 let js = super::BRIDGE_JS;
456
457 assert!(
458 js.contains("function nativeValueSetter("),
459 "BRIDGE_JS must define a nativeValueSetter helper that picks the prototype based on the element (#85)"
460 );
461
462 assert!(
465 js.contains("Object.getPrototypeOf(el)"),
466 "nativeValueSetter must derive the prototype from the element instance (#85)"
467 );
468
469 let buggy_pattern = "Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, \"value\") ||";
471 assert!(
472 !js.contains(buggy_pattern),
473 "fill/typeText must not use the `HTMLInputElement.prototype || HTMLTextAreaElement.prototype` short-circuit (#85)"
474 );
475
476 let body_of = |fn_decl: &str| -> &str {
481 let start = js.find(fn_decl).unwrap_or_else(|| panic!("{fn_decl} missing"));
482 let after = start + fn_decl.len();
483 let end = js[after..].find("\n function ").map_or(js.len(), |off| after + off);
484 &js[start..end]
485 };
486
487 let fill_body = body_of("function fill(params)");
488 let type_body = body_of("function typeText(params)");
489 let select_body = body_of("function select(params)");
490
491 assert!(fill_body.contains("nativeValueSetter("), "fill must call nativeValueSetter (#85)");
492 assert!(type_body.contains("nativeValueSetter("), "typeText must call nativeValueSetter (#85)");
493 assert!(
494 select_body.contains("nativeValueSetter("),
495 "select must call nativeValueSetter (#85) so a future textarea-style brand-check bug cannot reappear in any setter handler"
496 );
497
498 assert!(
506 select_body.contains("select requires a <select> element"),
507 "select must explicitly reject non-<select> targets after the nativeValueSetter refactor (#85)"
508 );
509 assert!(
510 !select_body.contains("instanceof HTMLSelectElement"),
511 "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)"
512 );
513
514 let fill_idx = js.find("function fill(params)").expect("fill function missing");
517 let helper_idx = js.find("function nativeValueSetter(").expect("nativeValueSetter helper missing");
518 assert!(helper_idx < fill_idx, "nativeValueSetter must be declared before fill (#85)");
519 }
520
521 #[cfg(all(any(unix, windows), debug_assertions))]
522 #[test]
523 fn bridge_role_map_maps_paragraph_and_keeps_it_noninteractive() {
524 let js = super::BRIDGE_JS;
528
529 assert!(
530 js.contains("P: \"paragraph\""),
531 "ROLE_MAP must map P to \"paragraph\" so snapshot includes <p> text (#109)"
532 );
533
534 let set_start = js.find("INTERACTIVE_ROLES = new Set([").expect("INTERACTIVE_ROLES set missing");
537 let set_body = &js[set_start..];
538 let set_end = set_body.find("]);").expect("INTERACTIVE_ROLES set unterminated");
539 assert!(
540 !set_body[..set_end].contains("\"paragraph\""),
541 "paragraph must stay out of INTERACTIVE_ROLES so interactive snapshots still exclude <p> (#109)"
542 );
543 }
544}