Skip to main content

tauri_plugin_hasgard/
lib.rs

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;
11// Native screenshot capture for the `screenshot_native` JSON-RPC method.
12// macOS-only today; non-macOS callers receive `PERMISSION_DENIED`.
13pub(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/// Initialize the tauri-hasgard plugin.
35///
36/// On non-Unix, non-Windows platforms or in release builds, returns a no-op plugin.
37/// In debug builds on Unix, injects the JS bridge, stores an `EvalEngine`,
38/// and starts a Unix socket server at `TAURI_HASGARD_SOCKET` when set, otherwise
39/// at `$XDG_RUNTIME_DIR/tauri-hasgard-{identifier}.sock`.
40/// In debug builds on Windows, starts a Named Pipe server at
41/// `\\.\pipe\tauri-hasgard-{identifier}` and registers the instance under `%LOCALAPPDATA%\tauri-hasgard\instances\`.
42#[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                // Unix binds with the std (sync) `UnixListener`, which needs no
70                // tokio runtime, so binding stays here in `setup` where a failure
71                // surfaces as a hard plugin error. `run` only upgrades the
72                // listener to tokio once it is already on the runtime.
73                #[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                // Windows' tokio `NamedPipeServer` registers with the reactor the
91                // instant it is created, so the bind must run inside the spawned
92                // task (which lives on the tokio runtime). Binding here in `setup`
93                // panics with "there is no reactor running, must be called from
94                // the context of a Tokio 1.x runtime" (#115).
95                #[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/// Strip path separators and unsafe characters from the app identifier
113/// so it can be safely used in a socket filename.
114#[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/// Create an eval function from the app handle that evaluates JS in a webview.
124///
125/// If `window` is `Some(label)`, targets that specific window (error if not found).
126/// If `window` is `None`, targets the conventional `main` window.
127#[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        // Results come back via the `__callback` IPC command (see
137        // EvalEngine::wrap_script). This eval is fire-and-forget; the IPC handler
138        // resolves the pending request, not this closure.
139        target.eval(&script).map_err(|e| e.to_string())
140    })
141}
142
143/// Create a focus function that requests OS focus for a webview window.
144///
145/// Resolution mirrors `make_eval_fn`: explicit label first, otherwise `main`.
146#[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/// Create a list function that enumerates all available webview windows.
181#[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        // BTreeMap iterates in sorted key order — no explicit sort needed
187        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        // #79: top-level `await` in user scripts must compile via the
295        // async-IIFE fallback stages instead of crashing with an opaque
296        // SyntaxError from indirect eval.
297        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        // Stage ordering: expression compile must precede the async fallbacks,
318        // and the async-expression stage must precede the indirect-eval path.
319        // Needles are formatting-stable substrings of the JS source, so a
320        // future `prettier`/`rustfmt` reflow of `bridge.js` does not silently
321        // break the ordering check.
322        let evalscript_idx = js.find("function evalScript(").expect("evalScript missing");
323        // SAFETY: the needle is ASCII, so `find()` returns a UTF-8 char boundary.
324        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        // #85: `fill` and `type` on a <textarea> threw
345        // "The HTMLInputElement.value setter can only be used on instances of HTMLInputElement"
346        // because the old code used
347        //   Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")
348        //   || Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value");
349        // The first descriptor is always truthy, so the textarea branch was unreachable
350        // and the input setter was applied to a textarea, violating the WebIDL brand check.
351        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        // The helper must use the element's actual prototype to support input,
359        // textarea, and select uniformly without violating the brand check.
360        assert!(
361            js.contains("Object.getPrototypeOf(el)"),
362            "nativeValueSetter must derive the prototype from the element instance (#85)"
363        );
364
365        // Buggy short-circuit must be gone from fill/typeText.
366        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        // Bound each function body by the start of the next `function ` declaration
373        // (or end-of-string), so the slice is immune to brace indentation changes
374        // and to nested blocks closing with the same brace pattern.
375        // ASCII needles → `find()` returns offsets that are valid UTF-8 char boundaries.
376        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        // The pre-refactor `select` relied on the WebIDL brand check to reject
395        // non-<select> targets implicitly. The helper drops that guarantee, so
396        // `select` must keep an explicit guard to fail fast on misrouted
397        // selectors instead of silently writing `value` on an unrelated
398        // element. The guard must be realm-safe (tag-based, not `instanceof`),
399        // because `nativeValueSetter` was added specifically to support
400        // elements coming from another window/iframe realm.
401        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        // Helper must be defined before its callers (hoisting works for `function`
411        // declarations, but ordering keeps the source readable for reviewers).
412        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        // #109: <p> text (e.g. the default Tauri template greeting rendered in
421        // a <p>) was dropped from snapshots because ROLE_MAP had no P entry, so
422        // getRole returned null and walk() never emitted the node.
423        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        // The paragraph role must stay non-interactive so `snapshot --interactive`
431        // still excludes <p>. Verify INTERACTIVE_ROLES does not list it.
432        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}