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, FocusFn, ListWindowsFn};
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 focus_fn = make_focus_fn(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(focus_fn),
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(focus_fn),
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))]
147fn make_focus_fn<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> FocusFn {
148 let handle = app.clone();
149 Arc::new(move |window: Option<&str>| {
150 let target = if let Some(label) = window {
151 handle.get_webview_window(label).ok_or_else(|| format!("Window '{label}' not found"))?
152 } else {
153 handle.get_webview_window("main").ok_or_else(|| "Window 'main' not found".to_owned())?
154 };
155 target.set_focus().map_err(|e| e.to_string())?;
156
157 #[cfg(windows)]
158 {
159 use std::sync::mpsc;
160 use std::time::Duration;
161 use webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC;
162
163 let (sender, receiver) = mpsc::sync_channel(1);
164 target
165 .with_webview(move |webview| {
166 let result = unsafe { webview.controller().MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC) }
167 .map_err(|error| error.to_string());
168 sender.send(result).expect("focus result receiver must exist");
169 })
170 .map_err(|error| error.to_string())?;
171 receiver
172 .recv_timeout(Duration::from_secs(2))
173 .map_err(|error| format!("WebView focus timed out: {error}"))??;
174 }
175
176 Ok(())
177 })
178}
179
180#[cfg(all(any(unix, windows), debug_assertions))]
182fn make_list_fn<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> ListWindowsFn {
183 let handle = app.clone();
184 Arc::new(move || {
185 let windows = handle.webview_windows();
186 let list: Result<Vec<serde_json::Value>, String> = windows
188 .iter()
189 .map(|(label, wv)| {
190 let url = wv.url().map_err(|error| format!("Failed to read URL for window '{label}': {error}"))?;
191 let title =
192 wv.title().map_err(|error| format!("Failed to read title for window '{label}': {error}"))?;
193 Ok(serde_json::json!({
194 "label": label,
195 "url": url.to_string(),
196 "title": title,
197 }))
198 })
199 .collect();
200 Ok(serde_json::json!({"windows": list?}))
201 })
202}
203
204#[cfg(test)]
205mod tests {
206 #[cfg(all(any(unix, windows), debug_assertions))]
207 #[test]
208 fn bridge_js_contains_html_to_image_and_hasgard() {
209 let js = super::BRIDGE_JS;
210 assert!(js.contains("htmlToImage"), "BRIDGE_JS must include the html-to-image IIFE bundle");
211 assert!(js.contains("window.__HASGARD__"), "BRIDGE_JS must include the hasgard bridge");
212 let html_idx = js.find("htmlToImage").expect("htmlToImage missing");
213 let hasgard_idx = js.find("window.__HASGARD__").expect("window.__HASGARD__ missing");
214 assert!(html_idx < hasgard_idx, "html-to-image must be injected before hasgard bridge code");
215 }
216
217 #[cfg(all(any(unix, windows), debug_assertions))]
218 #[test]
219 fn bridge_click_dispatches_pointer_sequence() {
220 let js = super::BRIDGE_JS;
221 let js_normalized: String = js.lines().collect::<Vec<_>>().join("\n");
222 let pointer_down_idx = js
223 .find(r#"dispatchPointerEvent(el, "pointerdown""#)
224 .expect("click must dispatch pointerdown for Radix triggers");
225 let mouse_down_idx = js.find(r#"MouseEvent("mousedown""#).expect("click must keep mousedown compatibility");
226 let pointer_up_idx = js
227 .find(r#"dispatchPointerEvent(el, "pointerup""#)
228 .expect("click must dispatch pointerup for Radix triggers");
229 let mouse_up_idx = js.find(r#"MouseEvent("mouseup""#).expect("click must keep mouseup compatibility");
230 let click_idx = js.find(r#"dispatchPointerEvent(el, "click""#).expect("click must dispatch as a pointer event");
231
232 assert!(
233 pointer_down_idx < mouse_down_idx
234 && mouse_down_idx < pointer_up_idx
235 && pointer_up_idx < mouse_up_idx
236 && mouse_up_idx < click_idx,
237 "click must dispatch pointerdown -> mousedown -> pointerup -> mouseup -> click"
238 );
239 assert!(js.contains(r#"pointerType: "mouse""#), "pointer events must include mouse pointer metadata");
240 assert!(
241 js_normalized.contains(
242 "if (pointerDownOk) {\n const mouseDownOk = el.dispatchEvent(new MouseEvent(\"mousedown\""
243 ),
244 "mousedown must only dispatch when pointerdown was not canceled"
245 );
246 assert!(
247 js_normalized.contains("if (pointerDownOk) {\n el.dispatchEvent(new MouseEvent(\"mouseup\""),
248 "mouseup must only dispatch when pointerdown was not canceled"
249 );
250 }
251
252 #[cfg(all(any(unix, windows), debug_assertions))]
253 #[test]
254 fn bridge_scroll_handles_top_and_bottom_directions() {
255 let js = super::BRIDGE_JS;
256 assert!(js.contains(r#"if (dir === "top")"#), "scroll must handle direction \"top\"");
257 assert!(js.contains(r#"if (dir === "bottom")"#), "scroll must handle direction \"bottom\"");
258 assert!(
259 js.contains("target.scrollTo(window.scrollX, 0)"),
260 "scroll top on window must preserve window.scrollX and set Y=0"
261 );
262 assert!(
263 js.contains("target.scrollTo(window.scrollX, Math.max(0, max))"),
264 "scroll bottom on window must preserve window.scrollX and clamp negative max"
265 );
266 assert!(
267 js.contains("Math.max(")
268 && js.contains("docEl ? docEl.scrollHeight : 0")
269 && js.contains("body ? body.scrollHeight : 0"),
270 "scroll bottom on window must use Math.max(documentElement.scrollHeight, body.scrollHeight) for quirks-mode safety"
271 );
272 assert!(
273 js.contains("docEl ? docEl.clientHeight : window.innerHeight"),
274 "scroll bottom on window must subtract docEl.clientHeight (excludes horizontal scrollbar) instead of window.innerHeight"
275 );
276 assert!(
277 js.contains("String(dir).slice(0, 64)"),
278 "scroll error message must cap user-supplied direction length"
279 );
280 assert!(js.contains("target.scrollTop = 0"), "scroll top on element must set scrollTop = 0");
281 assert!(
282 js.contains("target.scrollTop = Math.max(0, target.scrollHeight - target.clientHeight)"),
283 "scroll bottom on element must use scrollHeight - clientHeight (not raw scrollHeight)"
284 );
285 assert!(
286 js.contains("Unknown scroll direction:"),
287 "scroll must throw on unknown direction instead of silently no-op"
288 );
289 }
290
291 #[cfg(all(any(unix, windows), debug_assertions))]
292 #[test]
293 fn bridge_eval_auto_wraps_top_level_await() {
294 let js = super::BRIDGE_JS;
298 assert!(js.contains("function evalScript("), "BRIDGE_JS must define evalScript");
299 assert!(
300 js.contains("(async () => (\\n\" + script + \"\\n))()"),
301 "evalScript must include the async-expression compile stage (#79)"
302 );
303 assert!(
304 js.contains("hasTopLevelAwait(script)"),
305 "evalScript must guard the async fallbacks with hasTopLevelAwait (#79)"
306 );
307 assert!(
308 js.contains("(async () => {\\n\" + script + \"\\n})()"),
309 "evalScript must include the async-statement IIFE fallback (#79)"
310 );
311 assert!(js.contains("function hasTopLevelAwait("), "BRIDGE_JS must define the hasTopLevelAwait helper (#79)");
312 assert!(
313 js.contains("top-level await detected but the script could not be auto-wrapped"),
314 "evalScript must surface a clear error when auto-wrap fails (#79)"
315 );
316
317 let evalscript_idx = js.find("function evalScript(").expect("evalScript missing");
323 let body = &js[evalscript_idx..];
325 let expr_idx = body.find("\"return (\\n\" + script + \"\\n)\"").expect("stage 1 expression compile missing");
326 let async_expr_idx = body
327 .find("\"return (async () => (\\n\" + script + \"\\n))()\"")
328 .expect("stage 2 async-expression compile missing");
329 let async_stmt_idx = body
330 .find("\"return (async () => {\\n\" + script + \"\\n})()\"")
331 .expect("stage 3 async-statement IIFE missing");
332 let indirect_idx = body.find("var indirectEval = eval;").expect("indirect eval fallback missing");
333 assert!(expr_idx < async_expr_idx, "expression compile must precede async-expression fallback");
334 assert!(async_expr_idx < async_stmt_idx, "async-expression must precede async-statement fallback");
335 assert!(
336 async_stmt_idx < indirect_idx,
337 "async-statement IIFE must precede plain indirect eval (await guard runs first)"
338 );
339 }
340
341 #[cfg(all(any(unix, windows), debug_assertions))]
342 #[test]
343 fn bridge_native_value_setter_picks_prototype_per_element() {
344 let js = super::BRIDGE_JS;
352
353 assert!(
354 js.contains("function nativeValueSetter("),
355 "BRIDGE_JS must define a nativeValueSetter helper that picks the prototype based on the element (#85)"
356 );
357
358 assert!(
361 js.contains("Object.getPrototypeOf(el)"),
362 "nativeValueSetter must derive the prototype from the element instance (#85)"
363 );
364
365 let buggy_pattern = "Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, \"value\") ||";
367 assert!(
368 !js.contains(buggy_pattern),
369 "fill/typeText must not use the `HTMLInputElement.prototype || HTMLTextAreaElement.prototype` short-circuit (#85)"
370 );
371
372 let body_of = |fn_decl: &str| -> &str {
377 let start = js.find(fn_decl).unwrap_or_else(|| panic!("{fn_decl} missing"));
378 let after = start + fn_decl.len();
379 let end = js[after..].find("\n function ").map_or(js.len(), |off| after + off);
380 &js[start..end]
381 };
382
383 let fill_body = body_of("function fill(params)");
384 let type_body = body_of("function typeText(params)");
385 let select_body = body_of("function select(params)");
386
387 assert!(fill_body.contains("nativeValueSetter("), "fill must call nativeValueSetter (#85)");
388 assert!(type_body.contains("nativeValueSetter("), "typeText must call nativeValueSetter (#85)");
389 assert!(
390 select_body.contains("nativeValueSetter("),
391 "select must call nativeValueSetter (#85) so a future textarea-style brand-check bug cannot reappear in any setter handler"
392 );
393
394 assert!(
402 select_body.contains("select requires a <select> element"),
403 "select must explicitly reject non-<select> targets after the nativeValueSetter refactor (#85)"
404 );
405 assert!(
406 !select_body.contains("instanceof HTMLSelectElement"),
407 "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)"
408 );
409
410 let fill_idx = js.find("function fill(params)").expect("fill function missing");
413 let helper_idx = js.find("function nativeValueSetter(").expect("nativeValueSetter helper missing");
414 assert!(helper_idx < fill_idx, "nativeValueSetter must be declared before fill (#85)");
415 }
416
417 #[cfg(all(any(unix, windows), debug_assertions))]
418 #[test]
419 fn bridge_role_map_maps_paragraph_and_keeps_it_noninteractive() {
420 let js = super::BRIDGE_JS;
424
425 assert!(
426 js.contains("P: \"paragraph\""),
427 "ROLE_MAP must map P to \"paragraph\" so snapshot includes <p> text (#109)"
428 );
429
430 let set_start = js.find("INTERACTIVE_ROLES = new Set([").expect("INTERACTIVE_ROLES set missing");
433 let set_body = &js[set_start..];
434 let set_end = set_body.find("]);").expect("INTERACTIVE_ROLES set unterminated");
435 assert!(
436 !set_body[..set_end].contains("\"paragraph\""),
437 "paragraph must stay out of INTERACTIVE_ROLES so interactive snapshots still exclude <p> (#109)"
438 );
439 }
440}