Skip to main content

vs_engine_webkit/backend/
wpe.rs

1//! Linux backend — real `WebKitGTK 6` driven via `webkit6` + `glib`.
2//!
3//! # Threading model
4//!
5//! WebKitGTK is bound to the GLib main context of the thread that
6//! initialized GTK. Methods on [`WpeBackend`] must be called from that
7//! thread; the type is `!Send`. Production wiring (follow-up):
8//! `vs-cli::serve` on Linux must put GTK on the OS main thread and
9//! dispatch engine calls there via `MainContext::invoke`.
10//!
11//! # Status
12//!
13//! Real WebKitGTK-backed implementation of every primitive on the
14//! [`Engine`] trait: open / close / snapshot / act / wait / layout /
15//! set_viewport / save_auth / load_auth — plus inspector capture
16//! (console + network) wired through the `UserContentManager` script-
17//! message bridge. `capture` is the only outstanding piece (a TODO in
18//! a follow-up that calls `WebView::snapshot`).
19//!
20//! Verified on Linux CI with `libwebkitgtk-6.0-dev` + `libgtk-4-dev`
21//! installed; this file is not built on macOS.
22
23use std::cell::{Cell, RefCell};
24use std::collections::HashMap;
25use std::path::PathBuf;
26use std::rc::Rc;
27use std::time::Duration;
28
29use glib::prelude::*;
30use webkit6::gtk;
31use webkit6::prelude::*;
32use webkit6::{LoadEvent, UserContentInjectedFrames, UserScript, UserScriptInjectionTime, WebView};
33
34use vs_protocol::{Ref, Tree};
35
36use crate::engine::{
37    ActTarget, Action, AuthBlob, CaptureScope, CursorOp, Engine, EngineCapabilities, EngineError,
38    EngineResult, InputMode, LayoutBox, PageHandle, Viewport, WaitCondition,
39};
40
41// =============================================================================
42// Per-page state
43// =============================================================================
44
45struct WpePage {
46    web_view: WebView,
47    /// Hidden host window that owns `web_view` as its single child.
48    /// Required so the WebView has a `GdkSurface` on the X display —
49    /// without it XTest's synthetic input has no window to land on.
50    /// Created in `open()`, dropped on `close()`.
51    window: gtk::Window,
52    inspector: super::inspector_bridge::InspectorSlots,
53    /// True if the inspector install path actually succeeded for this
54    /// page (script + handlers registered without bail). Read by
55    /// `Engine::capabilities()` and the daemon `vs_inspect` gate.
56    inspector_installed: bool,
57    cookie_baseline: std::cell::RefCell<Option<Vec<super::auth::CookieData>>>,
58    cookie_next_seq: std::cell::RefCell<u64>,
59    /// Last known cursor position in screen-absolute CSS px. Updated
60    /// after every `cursor_op` so the next humanized lead-in starts
61    /// where the previous one ended. Defaults to (0, 0) on `open`.
62    last_mouse: Cell<vs_humanize::Point>,
63}
64
65// =============================================================================
66// Backend
67// =============================================================================
68
69/// Real WebKitGTK 6 backend. Construct on the GTK main thread; all
70/// subsequent calls must come from the same thread.
71pub struct WpeBackend {
72    pages: HashMap<PageHandle, WpePage>,
73    next_handle: u64,
74    captures_dir: Option<PathBuf>,
75}
76
77impl Default for WpeBackend {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl WpeBackend {
84    /// Build a backend. Caller must already have initialized GTK on
85    /// this thread (via `gtk4::init()`); production wiring lives in
86    /// `vs-cli::serve` on Linux. Constructing on a non-GTK thread will
87    /// panic the first time a `WebView` operation runs.
88    #[must_use]
89    pub fn new() -> Self {
90        Self {
91            pages: HashMap::new(),
92            next_handle: 1,
93            captures_dir: None,
94        }
95    }
96
97    /// Pin the directory where `capture` writes PNGs. Defaults to a
98    /// system temp subdirectory.
99    #[must_use]
100    pub fn with_capture_dir(mut self, dir: impl Into<PathBuf>) -> Self {
101        self.captures_dir = Some(dir.into());
102        self
103    }
104
105    fn alloc_handle(&mut self) -> PageHandle {
106        let h = PageHandle(self.next_handle);
107        self.next_handle += 1;
108        h
109    }
110
111    fn page_mut(&mut self, h: PageHandle) -> EngineResult<&mut WpePage> {
112        self.pages.get_mut(&h).ok_or(EngineError::NotFound {
113            kind: "page",
114            id: h.0.to_string(),
115        })
116    }
117}
118
119// =============================================================================
120// Run-loop helper: pump the GLib main context until a predicate fires
121// =============================================================================
122
123fn run_loop_until<F: FnMut() -> bool>(mut done: F, budget: Duration) -> bool {
124    let main_ctx = glib::MainContext::default();
125    let deadline = std::time::Instant::now() + budget;
126    while std::time::Instant::now() < deadline {
127        if done() {
128            return true;
129        }
130        // Iterate non-blocking; if no work, sleep briefly.
131        if !main_ctx.iteration(false) {
132            std::thread::sleep(Duration::from_millis(10));
133        }
134    }
135    done()
136}
137
138// =============================================================================
139// Shared payloads + parsers
140// =============================================================================
141
142use super::common::{parse_snapshot, SNAPSHOT_DOM_WALKER_JS as SNAPSHOT_JS};
143
144// =============================================================================
145// Engine impl
146// =============================================================================
147
148impl Engine for WpeBackend {
149    fn open(&mut self, url: &str) -> EngineResult<PageHandle> {
150        let web_view = WebView::new();
151
152        // Hidden host window for the WebView. v0.1.11 added native
153        // input dispatch (XTest / libei) for the cursor primitives;
154        // both require the WebView to have a real `GdkSurface` on the
155        // display server so the synthetic event has somewhere to land.
156        // `WebView::new()` alone renders to an internal offscreen
157        // surface with no display-server window — XTest events would
158        // disappear into the void. Wrapping each WebView in a
159        // decorated-off `gtk::Window` and presenting it gives us that
160        // surface; under `xvfb` (CI) the window has no visual effect.
161        let window = gtk::Window::new();
162        window.set_decorated(false);
163        window.set_default_size(1280, 800);
164        window.set_child(Some(&web_view));
165        window.present();
166
167        // Pin the User-Agent to a current Safari string so anti-bot
168        // fingerprinters don't flag the WebKitGTK default. See the
169        // commit log for `crate::engine::DEFAULT_USER_AGENT` rationale.
170        if let Some(settings) = WebViewExt::settings(&web_view) {
171            settings.set_user_agent(Some(crate::engine::DEFAULT_USER_AGENT));
172        }
173
174        // Inspector capture wiring — install the user script + register
175        // both message channels BEFORE load_uri so the bridge is live
176        // by document-start.
177        let inspector =
178            super::inspector_bridge::InspectorSlots::new(crate::inspector::DEFAULT_BUFFER_CAPACITY);
179        let inspector_installed = install_inspector(&web_view, &inspector);
180        web_view.load_uri(url);
181
182        // Wait for LoadEvent::Finished or LoadEvent::Failed via signal.
183        let slot: Rc<RefCell<Option<Result<(), String>>>> = Rc::new(RefCell::new(None));
184        let slot_for_signal = slot.clone();
185        let signal_id = web_view.connect_load_changed(move |_view, event| {
186            if event == LoadEvent::Finished {
187                *slot_for_signal.borrow_mut() = Some(Ok(()));
188            }
189        });
190        let slot_fail = slot.clone();
191        let fail_id = web_view.connect_load_failed(move |_view, _event, _uri, err| {
192            *slot_fail.borrow_mut() = Some(Err(err.message().to_string()));
193            // Stop event propagation: we have handled it.
194            true
195        });
196
197        let slot_check = slot.clone();
198        let ok = run_loop_until(
199            move || slot_check.borrow().is_some(),
200            Duration::from_secs(15),
201        );
202
203        // Disconnect signal handlers — the WebView outlives this scope.
204        web_view.disconnect(signal_id);
205        web_view.disconnect(fail_id);
206
207        if !ok {
208            return Err(EngineError::Timeout {
209                budget: Duration::from_secs(15),
210                primitive: "open",
211            });
212        }
213        match slot.borrow_mut().take() {
214            Some(Ok(())) => {}
215            Some(Err(msg)) => return Err(EngineError::Other(format!("navigation failed: {msg}"))),
216            None => unreachable!(),
217        }
218
219        let handle = self.alloc_handle();
220        self.pages.insert(
221            handle,
222            WpePage {
223                web_view,
224                window,
225                inspector,
226                inspector_installed,
227                cookie_baseline: std::cell::RefCell::new(None),
228                cookie_next_seq: std::cell::RefCell::new(0),
229                last_mouse: Cell::new(vs_humanize::Point { x: 0.0, y: 0.0 }),
230            },
231        );
232        Ok(handle)
233    }
234
235    fn close(&mut self, page: PageHandle) -> EngineResult<()> {
236        if let Some(p) = self.pages.remove(&page) {
237            // Dismiss the hidden host window so its `GdkSurface` is
238            // unmapped from the display server. `close()` queues the
239            // destroy; the GLib main context completes it on the next
240            // iteration.
241            p.window.close();
242        }
243        Ok(())
244    }
245
246    fn snapshot(&mut self, page: PageHandle) -> EngineResult<Tree> {
247        let p = self.page_mut(page)?;
248        let json = eval_js_string(&p.web_view, SNAPSHOT_JS, Duration::from_secs(5))?;
249        parse_snapshot(&json).map_err(EngineError::Other)
250    }
251
252    fn act(&mut self, page: PageHandle, target: ActTarget, action: Action) -> EngineResult<()> {
253        let p = self.page_mut(page)?;
254        let web_view = p.web_view.clone();
255        super::common::run_act(
256            move |js, budget| eval_js_string(&web_view, js, budget),
257            &target,
258            &action,
259        )
260    }
261
262    fn wait(
263        &mut self,
264        page: PageHandle,
265        cond: WaitCondition,
266        budget: Duration,
267    ) -> EngineResult<()> {
268        let p = self.page_mut(page)?;
269        let web_view = p.web_view.clone();
270        super::common::run_wait(
271            |js, budget| eval_js_string(&web_view, js, budget),
272            &cond,
273            budget,
274            || {
275                let _ = run_loop_until(|| false, Duration::from_millis(50));
276            },
277        )
278    }
279    fn layout(&mut self, page: PageHandle, refs: &[Ref]) -> EngineResult<Vec<LayoutBox>> {
280        let p = self.page_mut(page)?;
281        let web_view = p.web_view.clone();
282        super::common::run_layout(
283            move |js, budget| eval_js_string(&web_view, js, budget),
284            refs,
285        )
286    }
287    fn set_viewport(&mut self, page: PageHandle, viewport: Viewport) -> EngineResult<()> {
288        let p = self.page_mut(page)?;
289        let w = i32::try_from(viewport.width).unwrap_or(i32::MAX);
290        let h = i32::try_from(viewport.height).unwrap_or(i32::MAX);
291        // The host `gtk::Window` constrains its child WebView's
292        // allocation, so resizing the WebView via `set_size_request`
293        // alone is shadowed by the (1280x800) host. Resize both: the
294        // window's default size for the surface, and the WebView's
295        // size request so its render target matches the requested
296        // viewport.
297        p.window.set_default_size(w, h);
298        p.web_view.set_size_request(w, h);
299        Ok(())
300    }
301
302    fn capture(&mut self, page: PageHandle, _scope: CaptureScope) -> EngineResult<PathBuf> {
303        let captures_dir = self.captures_dir.clone().unwrap_or_else(std::env::temp_dir);
304        let _ = std::fs::create_dir_all(&captures_dir);
305        let path = captures_dir.join(format!("capture-{}.png", page.0));
306        let p = self.page_mut(page)?;
307        let web_view = p.web_view.clone();
308        let out_path = path.clone();
309
310        let slot: Rc<RefCell<Option<Result<(), String>>>> = Rc::new(RefCell::new(None));
311        let slot_for_cb = slot.clone();
312        web_view.snapshot(
313            webkit6::SnapshotRegion::FullDocument,
314            webkit6::SnapshotOptions::NONE,
315            None::<&webkit6::gio::Cancellable>,
316            move |result| {
317                *slot_for_cb.borrow_mut() = Some(match result {
318                    Ok(texture) => texture.save_to_png(&out_path).map_err(|e| e.to_string()),
319                    Err(e) => Err(e.to_string()),
320                });
321            },
322        );
323
324        let slot_check = slot.clone();
325        let ok = run_loop_until(
326            move || slot_check.borrow().is_some(),
327            Duration::from_secs(10),
328        );
329        if !ok {
330            return Err(EngineError::Timeout {
331                budget: Duration::from_secs(10),
332                primitive: "capture",
333            });
334        }
335        let result = slot.borrow_mut().take();
336        match result {
337            Some(Ok(())) => Ok(path),
338            Some(Err(msg)) => Err(EngineError::Other(format!("capture: {msg}"))),
339            None => unreachable!(),
340        }
341    }
342
343    fn save_auth(&mut self, page: PageHandle) -> EngineResult<AuthBlob> {
344        let p = self.page_mut(page)?;
345        let web_view = p.web_view.clone();
346        // Cookies: host-side via CookieManager so HttpOnly entries
347        // are captured. localStorage/sessionStorage: JS shim.
348        let cookies = wpe_cookies::get_all_cookies(&web_view)?;
349        let storage = super::common::run_save_storage_only(move |js, budget| {
350            eval_js_string(&web_view, js, budget)
351        })?;
352        let blob = super::auth::AuthBlobV2 {
353            version: 2,
354            url: storage.url,
355            origin: storage.origin,
356            cookies,
357            local_storage: storage.local_storage,
358            session_storage: storage.session_storage,
359        };
360        super::auth::encode(&blob)
361    }
362
363    fn load_auth(&mut self, page: PageHandle, blob: &AuthBlob) -> EngineResult<()> {
364        let p = self.page_mut(page)?;
365        let web_view = p.web_view.clone();
366        let parsed = super::auth::decode(blob)?;
367        wpe_cookies::set_cookies(&web_view, &parsed.cookies)?;
368        super::common::run_load_storage_only(
369            move |js, budget| eval_js_string(&web_view, js, budget),
370            &parsed.local_storage,
371            &parsed.session_storage,
372        )
373    }
374
375    fn console_entries(
376        &mut self,
377        page: PageHandle,
378    ) -> EngineResult<Vec<crate::inspector::ConsoleEntry>> {
379        let p = self.page_mut(page)?;
380        Ok(p.inspector.console.borrow().snapshot())
381    }
382
383    fn network_entries(
384        &mut self,
385        page: PageHandle,
386    ) -> EngineResult<Vec<crate::inspector::NetworkEntry>> {
387        let p = self.page_mut(page)?;
388        Ok(p.inspector.network.borrow().snapshot())
389    }
390
391    fn request_detail(
392        &mut self,
393        page: PageHandle,
394        seq: u64,
395    ) -> EngineResult<Option<crate::inspector::RequestDetail>> {
396        let p = self.page_mut(page)?;
397        Ok(p.inspector.details.borrow().get(seq).cloned())
398    }
399
400    fn eval_js(
401        &mut self,
402        page: PageHandle,
403        expr: &str,
404    ) -> EngineResult<crate::inspector::EvalResult> {
405        let p = self.page_mut(page)?;
406        let web_view = p.web_view.clone();
407        super::common::run_eval(
408            move |js, budget| eval_js_string(&web_view, js, budget),
409            expr,
410        )
411    }
412
413    fn storage(
414        &mut self,
415        page: PageHandle,
416        scope: crate::inspector::StorageScope,
417    ) -> EngineResult<Vec<crate::inspector::StorageEntry>> {
418        let p = self.page_mut(page)?;
419        let web_view = p.web_view.clone();
420        if matches!(scope, crate::inspector::StorageScope::Cookies) {
421            let cookies = wpe_cookies::get_all_cookies(&web_view)?;
422            return Ok(cookies
423                .iter()
424                .map(super::common::cookie_to_storage_entry)
425                .collect());
426        }
427        super::common::run_storage(
428            move |js, budget| eval_js_string(&web_view, js, budget),
429            scope,
430        )
431    }
432
433    fn scripts(&mut self, page: PageHandle) -> EngineResult<Vec<crate::inspector::ScriptEntry>> {
434        let p = self.page_mut(page)?;
435        let web_view = p.web_view.clone();
436        super::common::run_scripts(move |js, budget| eval_js_string(&web_view, js, budget))
437    }
438
439    fn script_source(
440        &mut self,
441        page: PageHandle,
442        seq: u64,
443    ) -> EngineResult<Option<crate::inspector::ScriptSource>> {
444        let p = self.page_mut(page)?;
445        let web_view = p.web_view.clone();
446        super::common::run_script_source(
447            move |js, budget| eval_js_string(&web_view, js, budget),
448            seq,
449        )
450    }
451
452    fn dom(
453        &mut self,
454        page: PageHandle,
455        r: Ref,
456        extra_props: &[String],
457    ) -> EngineResult<Option<crate::inspector::DomDetail>> {
458        let p = self.page_mut(page)?;
459        let web_view = p.web_view.clone();
460        super::common::run_dom(
461            move |js, budget| eval_js_string(&web_view, js, budget),
462            r,
463            extra_props,
464        )
465    }
466
467    fn performance(
468        &mut self,
469        page: PageHandle,
470    ) -> EngineResult<crate::inspector::PerformanceMetrics> {
471        let p = self.page_mut(page)?;
472        let web_view = p.web_view.clone();
473        super::common::run_performance(move |js, budget| eval_js_string(&web_view, js, budget))
474    }
475
476    fn cookie_events(
477        &mut self,
478        page: PageHandle,
479    ) -> EngineResult<Vec<crate::inspector::CookieEvent>> {
480        let p = self.page_mut(page)?;
481        let web_view = p.web_view.clone();
482        let current = wpe_cookies::get_all_cookies(&web_view)?;
483        let previous = p.cookie_baseline.borrow().clone();
484        let mut seq = p.cookie_next_seq.borrow_mut();
485        let events = super::common::diff_cookies(previous.as_deref(), &current, &mut seq);
486        *p.cookie_baseline.borrow_mut() = Some(current);
487        Ok(events)
488    }
489
490    fn cursor_op(&mut self, page: PageHandle, op: CursorOp, mode: InputMode) -> EngineResult<()> {
491        let p = self.page_mut(page)?;
492        let dispatcher = super::wpe_input::dispatcher()?;
493        let humanize_mode = match mode {
494            InputMode::Human => vs_humanize::InputMode::Human,
495            InputMode::Careful => vs_humanize::InputMode::Careful,
496            InputMode::Robotic => vs_humanize::InputMode::Robotic,
497        };
498        // Screen origin of the hidden host window. v0.1.11 assumes
499        // (0, 0) — true under `xvfb` (CI) and any session without a
500        // window manager. With a WM, the WM may have translated the
501        // window elsewhere; a follow-up PR will query
502        // `GdkX11Surface::xid()` + `XTranslateCoordinates` to read the
503        // real origin. Until then, page-local CSS px == screen px,
504        // which is fine for CI and most headless setups.
505        let (origin_x, origin_y) = (0.0_f64, 0.0_f64);
506        let start = p.last_mouse.get();
507        let seed = humanize_seed(op);
508        let landed = match op {
509            CursorOp::MoveTo { x, y } | CursorOp::HoverAt { x, y } => {
510                cursor_move_along_path(dispatcher, start, vs_humanize::Point { x, y },
511                    (origin_x, origin_y), humanize_mode, seed, false)?
512            }
513            CursorOp::ClickAt { x, y } => {
514                let landed = cursor_move_along_path(dispatcher, start, vs_humanize::Point { x, y },
515                    (origin_x, origin_y), humanize_mode, seed, false)?;
516                cursor_press_release(dispatcher, landed, (origin_x, origin_y))?;
517                landed
518            }
519            CursorOp::Drag { x1, y1, x2, y2 } => {
520                let start_pt = vs_humanize::Point { x: x1, y: y1 };
521                let target = vs_humanize::Point { x: x2, y: y2 };
522                let pre = cursor_move_along_path(dispatcher, start, start_pt,
523                    (origin_x, origin_y), humanize_mode, seed, false)?;
524                dispatcher.dispatch(super::wpe_input::InputEvent::Press(
525                    super::wpe_input::Button::Left,
526                ))?;
527                std::thread::sleep(Duration::from_millis(15));
528                let landed = cursor_move_along_path(dispatcher, pre, target,
529                    (origin_x, origin_y), humanize_mode, seed, true)?;
530                dispatcher.dispatch(super::wpe_input::InputEvent::Release(
531                    super::wpe_input::Button::Left,
532                ))?;
533                dispatcher.flush()?;
534                // After the OS-level drag, fire the HTML5 DragEvent
535                // chain in JS so react-dnd / React-Flow HTML5 targets
536                // observe the drop.
537                let html5_js = super::common::build_html5_drag_js(x1, y1, x2, y2);
538                let web_view = p.web_view.clone();
539                let _ = eval_js_string(&web_view, &html5_js, Duration::from_secs(2));
540                landed
541            }
542        };
543        p.last_mouse.set(landed);
544        Ok(())
545    }
546
547
548    fn capabilities(&self) -> EngineCapabilities {
549        let any_inspector = self.pages.values().any(|p| p.inspector_installed);
550        EngineCapabilities {
551            renders: true,
552            honors_viewport: true,
553            measures_layout: true,
554            persists_auth: true,
555            inspector_console: any_inspector,
556            inspector_network: any_inspector,
557            inspector_cookie_events: true,
558            name: "wpe",
559            version: "Linux WebKitGTK 6 (webkit6)",
560        }
561    }
562}
563
564// =============================================================================
565// JS evaluation helper
566// =============================================================================
567
568// =============================================================================
569// Cursor primitive helpers (XTest / libei input dispatch)
570// =============================================================================
571
572/// Deterministic seed for the humanize Bezier path so the same
573/// `cursor_op` invocation produces the same trajectory on every run.
574fn humanize_seed(op: CursorOp) -> u64 {
575    // Hash the op's coordinates into a u64. Trivial mixer; replays
576    // need determinism, not cryptographic spread.
577    let (a, b, c, d) = match op {
578        CursorOp::MoveTo { x, y } | CursorOp::HoverAt { x, y } | CursorOp::ClickAt { x, y } => {
579            (x, y, 0.0, 0.0)
580        }
581        CursorOp::Drag { x1, y1, x2, y2 } => (x1, y1, x2, y2),
582    };
583    let bits = |v: f64| v.to_bits();
584    bits(a).wrapping_mul(0x9E37_79B9_7F4A_7C15)
585        ^ bits(b).wrapping_mul(0xBF58_476D_1CE4_E5B9)
586        ^ bits(c).wrapping_mul(0x94D0_49BB_1331_11EB)
587        ^ bits(d)
588}
589
590/// Walk a Bezier path from `start` to `end`, dispatching one `Move`
591/// per step at the path's prescribed timing. Returns the final point.
592/// The `button_down` flag is unused on Linux today (XTest doesn't
593/// distinguish drag-move from plain-move — both are `MotionNotify`
594/// events; the held button state is tracked at the X server). Kept
595/// in the signature so drag callers stay symmetric with the macOS
596/// `move_along_path`.
597fn cursor_move_along_path(
598    dispatcher: &dyn super::wpe_input::InputDispatcher,
599    start: vs_humanize::Point,
600    end: vs_humanize::Point,
601    origin: (f64, f64),
602    mode: vs_humanize::InputMode,
603    seed: u64,
604    _button_down: bool,
605) -> EngineResult<vs_humanize::Point> {
606    let path = vs_humanize::mouse_path(start, end, mode, seed);
607    let mut prev_ms: u128 = 0;
608    for step in &path {
609        if step.kind != vs_humanize::MouseStepKind::Move {
610            continue;
611        }
612        let screen = super::wpe_input::ScreenPoint {
613            #[allow(clippy::cast_possible_truncation)]
614            x: (origin.0 + step.point.x).round() as i32,
615            #[allow(clippy::cast_possible_truncation)]
616            y: (origin.1 + step.point.y).round() as i32,
617        };
618        dispatcher.dispatch(super::wpe_input::InputEvent::Move(screen))?;
619        let dt = step.at.as_millis().saturating_sub(prev_ms);
620        if dt > 0 {
621            std::thread::sleep(Duration::from_millis(u64::try_from(dt).unwrap_or(0)));
622        }
623        prev_ms = step.at.as_millis();
624    }
625    // Final settling move ending exactly at `end`.
626    let final_pt = super::wpe_input::ScreenPoint {
627        #[allow(clippy::cast_possible_truncation)]
628        x: (origin.0 + end.x).round() as i32,
629        #[allow(clippy::cast_possible_truncation)]
630        y: (origin.1 + end.y).round() as i32,
631    };
632    dispatcher.dispatch(super::wpe_input::InputEvent::Move(final_pt))?;
633    dispatcher.flush()?;
634    Ok(end)
635}
636
637/// Press-then-release left button at the current pointer position.
638/// Matches the `mouseDown` / 15 ms / `mouseUp` cadence the macOS
639/// backend uses so JS sees a coherent `click` event.
640fn cursor_press_release(
641    dispatcher: &dyn super::wpe_input::InputDispatcher,
642    _at: vs_humanize::Point,
643    _origin: (f64, f64),
644) -> EngineResult<()> {
645    dispatcher.dispatch(super::wpe_input::InputEvent::Press(
646        super::wpe_input::Button::Left,
647    ))?;
648    std::thread::sleep(Duration::from_millis(15));
649    dispatcher.dispatch(super::wpe_input::InputEvent::Release(
650        super::wpe_input::Button::Left,
651    ))?;
652    dispatcher.flush()?;
653    std::thread::sleep(Duration::from_millis(30));
654    Ok(())
655}
656
657fn eval_js_string(web_view: &WebView, js: &str, budget: Duration) -> EngineResult<String> {
658    let slot: Rc<RefCell<Option<Result<String, String>>>> = Rc::new(RefCell::new(None));
659    let slot_for_cb = slot.clone();
660
661    let cancellable = webkit6::gio::Cancellable::new();
662    web_view.evaluate_javascript(
663        js,
664        None,
665        None,
666        Some(&cancellable),
667        move |result| match result {
668            Ok(value) => {
669                let s = value.to_string();
670                *slot_for_cb.borrow_mut() = Some(Ok(s));
671            }
672            Err(err) => {
673                *slot_for_cb.borrow_mut() = Some(Err(err.to_string()));
674            }
675        },
676    );
677
678    let slot_check = slot.clone();
679    let ok = run_loop_until(move || slot_check.borrow().is_some(), budget);
680    if !ok {
681        return Err(EngineError::Timeout {
682            budget,
683            primitive: "eval",
684        });
685    }
686    let result = slot.borrow_mut().take();
687    match result {
688        Some(Ok(s)) => Ok(s),
689        Some(Err(msg)) => Err(EngineError::Other(format!("eval failed: {msg}"))),
690        None => unreachable!(),
691    }
692}
693
694// =============================================================================
695// Inspector capture wiring
696// =============================================================================
697
698fn install_inspector(web_view: &WebView, slots: &super::inspector_bridge::InspectorSlots) -> bool {
699    use super::inspector_bridge::{
700        self, InspectorSlots, NetworkIngestSlot, CONSOLE_HANDLER, NETWORK_HANDLER,
701    };
702
703    if std::env::var_os("VS_DISABLE_INSPECTOR").is_some() {
704        return false;
705    }
706
707    let manager = web_view.user_content_manager().expect(
708        "WebKitGTK WebView is missing a UserContentManager — should not happen on a default WebView",
709    );
710
711    let script = UserScript::new(
712        inspector_bridge::SCRIPT,
713        UserContentInjectedFrames::AllFrames,
714        UserScriptInjectionTime::Start,
715        &[],
716        &[],
717    );
718    manager.add_script(&script);
719
720    if !manager.register_script_message_handler(CONSOLE_HANDLER, None) {
721        return false;
722    }
723    if !manager.register_script_message_handler(NETWORK_HANDLER, None) {
724        return false;
725    }
726
727    let console_slots: InspectorSlots = slots.clone();
728    manager.connect_script_message_received(Some(CONSOLE_HANDLER), move |_, value| {
729        let json = value.to_string();
730        let mut buf = console_slots.console.borrow_mut();
731        inspector_bridge::ingest_console(&mut buf, &json);
732    });
733    let network_slots: InspectorSlots = slots.clone();
734    manager.connect_script_message_received(Some(NETWORK_HANDLER), move |_, value| {
735        let json = value.to_string();
736        let mut entries = network_slots.network.borrow_mut();
737        let mut details = network_slots.details.borrow_mut();
738        let mut pending = network_slots.pending.borrow_mut();
739        inspector_bridge::ingest_network(
740            NetworkIngestSlot {
741                entries: &mut entries,
742                details: &mut details,
743                pending: &mut pending,
744            },
745            &json,
746        );
747    });
748    true
749}
750
751// =============================================================================
752// Host-side cookie save/load via WebKitCookieManager
753// =============================================================================
754//
755// Mirror of the macOS `webkit::cookie_store` module. `document.cookie`
756// can't see or write `HttpOnly` cookies; `WebKitCookieManager` can.
757// The v0.1.2 fix routes auth save/load through this path on every
758// platform.
759mod wpe_cookies {
760    use std::cell::RefCell;
761    use std::rc::Rc;
762    use std::time::Duration;
763
764    use webkit6::gio;
765    use webkit6::glib;
766    use webkit6::prelude::*;
767    use webkit6::soup;
768    use webkit6::WebView;
769
770    use crate::backend::auth::CookieData;
771    use crate::engine::{EngineError, EngineResult};
772
773    use super::run_loop_until;
774
775    const ASYNC_BUDGET: Duration = Duration::from_secs(5);
776
777    fn cookie_manager(web_view: &WebView) -> EngineResult<webkit6::CookieManager> {
778        let session = WebViewExt::network_session(web_view).ok_or_else(|| {
779            EngineError::Other("WebView has no NetworkSession; cookies unavailable".into())
780        })?;
781        session
782            .cookie_manager()
783            .ok_or_else(|| EngineError::Other("NetworkSession has no CookieManager".into()))
784    }
785
786    pub(super) fn get_all_cookies(web_view: &WebView) -> EngineResult<Vec<CookieData>> {
787        let manager = cookie_manager(web_view)?;
788        let slot: Rc<RefCell<Option<Vec<CookieData>>>> = Rc::new(RefCell::new(None));
789        let slot_cb = slot.clone();
790        manager.all_cookies(None::<&gio::Cancellable>, move |result| {
791            let cookies = match result {
792                Ok(v) => v.into_iter().map(|mut c| serialize(&mut c)).collect(),
793                Err(_) => Vec::new(),
794            };
795            *slot_cb.borrow_mut() = Some(cookies);
796        });
797        let check = slot.clone();
798        let ok = run_loop_until(move || check.borrow().is_some(), ASYNC_BUDGET);
799        if !ok {
800            return Err(EngineError::Timeout {
801                budget: ASYNC_BUDGET,
802                primitive: "save_auth (all_cookies)",
803            });
804        }
805        let result = slot.borrow_mut().take().unwrap_or_default();
806        Ok(result)
807    }
808
809    pub(super) fn set_cookies(web_view: &WebView, cookies: &[CookieData]) -> EngineResult<()> {
810        let manager = cookie_manager(web_view)?;
811        for c in cookies {
812            if c.name.is_empty() || c.domain.is_empty() {
813                continue;
814            }
815            let path = if c.path.is_empty() {
816                "/"
817            } else {
818                c.path.as_str()
819            };
820            // max_age=-1: session cookie (no expiration). We set
821            // expires explicitly below if the saved blob carried it.
822            let mut cookie = soup::Cookie::new(&c.name, &c.value, &c.domain, path, -1);
823            cookie.set_secure(c.secure);
824            cookie.set_http_only(c.http_only);
825            if let Some(unix) = c.expires_unix {
826                if let Ok(dt) = glib::DateTime::from_unix_utc(unix) {
827                    cookie.set_expires(&dt);
828                }
829            }
830            if let Some(ss) = c.same_site.as_deref() {
831                let policy = match ss {
832                    "Strict" => soup::SameSitePolicy::Strict,
833                    "None" => soup::SameSitePolicy::None,
834                    _ => soup::SameSitePolicy::Lax,
835                };
836                cookie.set_same_site_policy(policy);
837            }
838
839            let done: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
840            let done_cb = done.clone();
841            manager.add_cookie(&cookie, None::<&gio::Cancellable>, move |_| {
842                *done_cb.borrow_mut() = true;
843            });
844            let check = done.clone();
845            let ok = run_loop_until(move || *check.borrow(), ASYNC_BUDGET);
846            if !ok {
847                return Err(EngineError::Timeout {
848                    budget: ASYNC_BUDGET,
849                    primitive: "load_auth (add_cookie)",
850                });
851            }
852        }
853        Ok(())
854    }
855
856    fn serialize(c: &mut soup::Cookie) -> CookieData {
857        CookieData {
858            name: c.name().map(|s| s.to_string()).unwrap_or_default(),
859            value: c.value().map(|s| s.to_string()).unwrap_or_default(),
860            domain: c.domain().map(|s| s.to_string()).unwrap_or_default(),
861            path: c.path().map(|s| s.to_string()).unwrap_or_default(),
862            expires_unix: c.expires().map(|dt| dt.to_unix()),
863            secure: c.is_secure(),
864            http_only: c.is_http_only(),
865            same_site: match c.same_site_policy() {
866                soup::SameSitePolicy::Strict => Some("Strict".to_string()),
867                soup::SameSitePolicy::None => Some("None".to_string()),
868                soup::SameSitePolicy::Lax => Some("Lax".to_string()),
869                _ => None,
870            },
871        }
872    }
873}