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::RefCell;
24use std::collections::HashMap;
25use std::path::PathBuf;
26use std::rc::Rc;
27use std::time::Duration;
28
29use glib::prelude::*;
30use webkit6::prelude::*;
31use webkit6::{LoadEvent, UserContentInjectedFrames, UserScript, UserScriptInjectionTime, WebView};
32
33use vs_protocol::{Ref, Tree};
34
35use crate::engine::{
36    ActTarget, Action, AuthBlob, CaptureScope, Engine, EngineCapabilities, EngineError,
37    EngineResult, LayoutBox, PageHandle, Viewport, WaitCondition,
38};
39
40// =============================================================================
41// Per-page state
42// =============================================================================
43
44struct WpePage {
45    web_view: WebView,
46    inspector: super::inspector_bridge::InspectorSlots,
47    /// True if the inspector install path actually succeeded for this
48    /// page (script + handlers registered without bail). Read by
49    /// `Engine::capabilities()` and the daemon `vs_inspect` gate.
50    inspector_installed: bool,
51}
52
53// =============================================================================
54// Backend
55// =============================================================================
56
57/// Real WebKitGTK 6 backend. Construct on the GTK main thread; all
58/// subsequent calls must come from the same thread.
59pub struct WpeBackend {
60    pages: HashMap<PageHandle, WpePage>,
61    next_handle: u64,
62    captures_dir: Option<PathBuf>,
63}
64
65impl Default for WpeBackend {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl WpeBackend {
72    /// Build a backend. Caller must already have initialized GTK on
73    /// this thread (via `gtk4::init()`); production wiring lives in
74    /// `vs-cli::serve` on Linux. Constructing on a non-GTK thread will
75    /// panic the first time a `WebView` operation runs.
76    #[must_use]
77    pub fn new() -> Self {
78        Self {
79            pages: HashMap::new(),
80            next_handle: 1,
81            captures_dir: None,
82        }
83    }
84
85    /// Pin the directory where `capture` writes PNGs. Defaults to a
86    /// system temp subdirectory.
87    #[must_use]
88    pub fn with_capture_dir(mut self, dir: impl Into<PathBuf>) -> Self {
89        self.captures_dir = Some(dir.into());
90        self
91    }
92
93    fn alloc_handle(&mut self) -> PageHandle {
94        let h = PageHandle(self.next_handle);
95        self.next_handle += 1;
96        h
97    }
98
99    fn page_mut(&mut self, h: PageHandle) -> EngineResult<&mut WpePage> {
100        self.pages.get_mut(&h).ok_or(EngineError::NotFound {
101            kind: "page",
102            id: h.0.to_string(),
103        })
104    }
105}
106
107// =============================================================================
108// Run-loop helper: pump the GLib main context until a predicate fires
109// =============================================================================
110
111fn run_loop_until<F: FnMut() -> bool>(mut done: F, budget: Duration) -> bool {
112    let main_ctx = glib::MainContext::default();
113    let deadline = std::time::Instant::now() + budget;
114    while std::time::Instant::now() < deadline {
115        if done() {
116            return true;
117        }
118        // Iterate non-blocking; if no work, sleep briefly.
119        if !main_ctx.iteration(false) {
120            std::thread::sleep(Duration::from_millis(10));
121        }
122    }
123    done()
124}
125
126// =============================================================================
127// Shared payloads + parsers
128// =============================================================================
129
130use super::common::{parse_snapshot, SNAPSHOT_DOM_WALKER_JS as SNAPSHOT_JS};
131
132// =============================================================================
133// Engine impl
134// =============================================================================
135
136impl Engine for WpeBackend {
137    fn open(&mut self, url: &str) -> EngineResult<PageHandle> {
138        let web_view = WebView::new();
139
140        // Pin the User-Agent to a current Safari string so anti-bot
141        // fingerprinters don't flag the WebKitGTK default. See the
142        // commit log for `crate::engine::DEFAULT_USER_AGENT` rationale.
143        if let Some(settings) = WebViewExt::settings(&web_view) {
144            settings.set_user_agent(Some(crate::engine::DEFAULT_USER_AGENT));
145        }
146
147        // Inspector capture wiring — install the user script + register
148        // both message channels BEFORE load_uri so the bridge is live
149        // by document-start.
150        let inspector =
151            super::inspector_bridge::InspectorSlots::new(crate::inspector::DEFAULT_BUFFER_CAPACITY);
152        let inspector_installed = install_inspector(&web_view, &inspector);
153        web_view.load_uri(url);
154
155        // Wait for LoadEvent::Finished or LoadEvent::Failed via signal.
156        let slot: Rc<RefCell<Option<Result<(), String>>>> = Rc::new(RefCell::new(None));
157        let slot_for_signal = slot.clone();
158        let signal_id = web_view.connect_load_changed(move |_view, event| {
159            if event == LoadEvent::Finished {
160                *slot_for_signal.borrow_mut() = Some(Ok(()));
161            }
162        });
163        let slot_fail = slot.clone();
164        let fail_id = web_view.connect_load_failed(move |_view, _event, _uri, err| {
165            *slot_fail.borrow_mut() = Some(Err(err.message().to_string()));
166            // Stop event propagation: we have handled it.
167            true
168        });
169
170        let slot_check = slot.clone();
171        let ok = run_loop_until(
172            move || slot_check.borrow().is_some(),
173            Duration::from_secs(15),
174        );
175
176        // Disconnect signal handlers — the WebView outlives this scope.
177        web_view.disconnect(signal_id);
178        web_view.disconnect(fail_id);
179
180        if !ok {
181            return Err(EngineError::Timeout {
182                budget: Duration::from_secs(15),
183                primitive: "open",
184            });
185        }
186        match slot.borrow_mut().take() {
187            Some(Ok(())) => {}
188            Some(Err(msg)) => return Err(EngineError::Other(format!("navigation failed: {msg}"))),
189            None => unreachable!(),
190        }
191
192        let handle = self.alloc_handle();
193        self.pages.insert(
194            handle,
195            WpePage {
196                web_view,
197                inspector,
198                inspector_installed,
199            },
200        );
201        Ok(handle)
202    }
203
204    fn close(&mut self, page: PageHandle) -> EngineResult<()> {
205        self.pages.remove(&page);
206        Ok(())
207    }
208
209    fn snapshot(&mut self, page: PageHandle) -> EngineResult<Tree> {
210        let p = self.page_mut(page)?;
211        let json = eval_js_string(&p.web_view, SNAPSHOT_JS, Duration::from_secs(5))?;
212        parse_snapshot(&json).map_err(EngineError::Other)
213    }
214
215    fn act(&mut self, page: PageHandle, target: ActTarget, action: Action) -> EngineResult<()> {
216        let p = self.page_mut(page)?;
217        let web_view = p.web_view.clone();
218        super::common::run_act(
219            move |js, budget| eval_js_string(&web_view, js, budget),
220            &target,
221            &action,
222        )
223    }
224
225    fn wait(
226        &mut self,
227        page: PageHandle,
228        cond: WaitCondition,
229        budget: Duration,
230    ) -> EngineResult<()> {
231        let p = self.page_mut(page)?;
232        let web_view = p.web_view.clone();
233        super::common::run_wait(
234            |js, budget| eval_js_string(&web_view, js, budget),
235            &cond,
236            budget,
237            || {
238                let _ = run_loop_until(|| false, Duration::from_millis(50));
239            },
240        )
241    }
242    fn layout(&mut self, page: PageHandle, refs: &[Ref]) -> EngineResult<Vec<LayoutBox>> {
243        let p = self.page_mut(page)?;
244        let web_view = p.web_view.clone();
245        super::common::run_layout(
246            move |js, budget| eval_js_string(&web_view, js, budget),
247            refs,
248        )
249    }
250    fn set_viewport(&mut self, page: PageHandle, viewport: Viewport) -> EngineResult<()> {
251        let p = self.page_mut(page)?;
252        // WebKitGTK doesn't expose a per-WebView viewport API the way
253        // WKWebView's setFrame does on macOS. Resize via the GTK widget
254        // size request — the WebView is itself a Widget.
255        p.web_view.set_size_request(
256            i32::try_from(viewport.width).unwrap_or(i32::MAX),
257            i32::try_from(viewport.height).unwrap_or(i32::MAX),
258        );
259        Ok(())
260    }
261
262    fn capture(&mut self, page: PageHandle, _scope: CaptureScope) -> EngineResult<PathBuf> {
263        let captures_dir = self.captures_dir.clone().unwrap_or_else(std::env::temp_dir);
264        let _ = std::fs::create_dir_all(&captures_dir);
265        let path = captures_dir.join(format!("capture-{}.png", page.0));
266        let p = self.page_mut(page)?;
267        let web_view = p.web_view.clone();
268        let out_path = path.clone();
269
270        let slot: Rc<RefCell<Option<Result<(), String>>>> = Rc::new(RefCell::new(None));
271        let slot_for_cb = slot.clone();
272        web_view.snapshot(
273            webkit6::SnapshotRegion::FullDocument,
274            webkit6::SnapshotOptions::NONE,
275            None::<&webkit6::gio::Cancellable>,
276            move |result| {
277                *slot_for_cb.borrow_mut() = Some(match result {
278                    Ok(texture) => texture.save_to_png(&out_path).map_err(|e| e.to_string()),
279                    Err(e) => Err(e.to_string()),
280                });
281            },
282        );
283
284        let slot_check = slot.clone();
285        let ok = run_loop_until(
286            move || slot_check.borrow().is_some(),
287            Duration::from_secs(10),
288        );
289        if !ok {
290            return Err(EngineError::Timeout {
291                budget: Duration::from_secs(10),
292                primitive: "capture",
293            });
294        }
295        let result = slot.borrow_mut().take();
296        match result {
297            Some(Ok(())) => Ok(path),
298            Some(Err(msg)) => Err(EngineError::Other(format!("capture: {msg}"))),
299            None => unreachable!(),
300        }
301    }
302
303    fn save_auth(&mut self, page: PageHandle) -> EngineResult<AuthBlob> {
304        let p = self.page_mut(page)?;
305        let web_view = p.web_view.clone();
306        // Cookies: host-side via CookieManager so HttpOnly entries
307        // are captured. localStorage/sessionStorage: JS shim.
308        let cookies = wpe_cookies::get_all_cookies(&web_view)?;
309        let storage = super::common::run_save_storage_only(move |js, budget| {
310            eval_js_string(&web_view, js, budget)
311        })?;
312        let blob = super::auth::AuthBlobV2 {
313            version: 2,
314            url: storage.url,
315            origin: storage.origin,
316            cookies,
317            local_storage: storage.local_storage,
318            session_storage: storage.session_storage,
319        };
320        super::auth::encode(&blob)
321    }
322
323    fn load_auth(&mut self, page: PageHandle, blob: &AuthBlob) -> EngineResult<()> {
324        let p = self.page_mut(page)?;
325        let web_view = p.web_view.clone();
326        let parsed = super::auth::decode(blob)?;
327        wpe_cookies::set_cookies(&web_view, &parsed.cookies)?;
328        super::common::run_load_storage_only(
329            move |js, budget| eval_js_string(&web_view, js, budget),
330            &parsed.local_storage,
331            &parsed.session_storage,
332        )
333    }
334
335    fn console_entries(
336        &mut self,
337        page: PageHandle,
338    ) -> EngineResult<Vec<crate::inspector::ConsoleEntry>> {
339        let p = self.page_mut(page)?;
340        Ok(p.inspector.console.borrow().snapshot())
341    }
342
343    fn network_entries(
344        &mut self,
345        page: PageHandle,
346    ) -> EngineResult<Vec<crate::inspector::NetworkEntry>> {
347        let p = self.page_mut(page)?;
348        Ok(p.inspector.network.borrow().snapshot())
349    }
350
351    fn request_detail(
352        &mut self,
353        page: PageHandle,
354        seq: u64,
355    ) -> EngineResult<Option<crate::inspector::RequestDetail>> {
356        let p = self.page_mut(page)?;
357        Ok(p.inspector.details.borrow().get(seq).cloned())
358    }
359
360    fn eval_js(
361        &mut self,
362        page: PageHandle,
363        expr: &str,
364    ) -> EngineResult<crate::inspector::EvalResult> {
365        let p = self.page_mut(page)?;
366        let web_view = p.web_view.clone();
367        super::common::run_eval(
368            move |js, budget| eval_js_string(&web_view, js, budget),
369            expr,
370        )
371    }
372
373    fn storage(
374        &mut self,
375        page: PageHandle,
376        scope: crate::inspector::StorageScope,
377    ) -> EngineResult<Vec<crate::inspector::StorageEntry>> {
378        let p = self.page_mut(page)?;
379        let web_view = p.web_view.clone();
380        if matches!(scope, crate::inspector::StorageScope::Cookies) {
381            let cookies = wpe_cookies::get_all_cookies(&web_view)?;
382            return Ok(cookies
383                .iter()
384                .map(super::common::cookie_to_storage_entry)
385                .collect());
386        }
387        super::common::run_storage(
388            move |js, budget| eval_js_string(&web_view, js, budget),
389            scope,
390        )
391    }
392
393    fn scripts(&mut self, page: PageHandle) -> EngineResult<Vec<crate::inspector::ScriptEntry>> {
394        let p = self.page_mut(page)?;
395        let web_view = p.web_view.clone();
396        super::common::run_scripts(move |js, budget| eval_js_string(&web_view, js, budget))
397    }
398
399    fn script_source(
400        &mut self,
401        page: PageHandle,
402        seq: u64,
403    ) -> EngineResult<Option<crate::inspector::ScriptSource>> {
404        let p = self.page_mut(page)?;
405        let web_view = p.web_view.clone();
406        super::common::run_script_source(
407            move |js, budget| eval_js_string(&web_view, js, budget),
408            seq,
409        )
410    }
411
412    fn dom(
413        &mut self,
414        page: PageHandle,
415        r: Ref,
416        extra_props: &[String],
417    ) -> EngineResult<Option<crate::inspector::DomDetail>> {
418        let p = self.page_mut(page)?;
419        let web_view = p.web_view.clone();
420        super::common::run_dom(
421            move |js, budget| eval_js_string(&web_view, js, budget),
422            r,
423            extra_props,
424        )
425    }
426
427    fn performance(
428        &mut self,
429        page: PageHandle,
430    ) -> EngineResult<crate::inspector::PerformanceMetrics> {
431        let p = self.page_mut(page)?;
432        let web_view = p.web_view.clone();
433        super::common::run_performance(move |js, budget| eval_js_string(&web_view, js, budget))
434    }
435
436    fn capabilities(&self) -> EngineCapabilities {
437        let any_inspector = self.pages.values().any(|p| p.inspector_installed);
438        EngineCapabilities {
439            renders: true,
440            honors_viewport: true,
441            measures_layout: true,
442            persists_auth: true,
443            inspector_console: any_inspector,
444            inspector_network: any_inspector,
445            name: "wpe",
446            version: "Linux WebKitGTK 6 (webkit6)",
447        }
448    }
449}
450
451// =============================================================================
452// JS evaluation helper
453// =============================================================================
454
455fn eval_js_string(web_view: &WebView, js: &str, budget: Duration) -> EngineResult<String> {
456    let slot: Rc<RefCell<Option<Result<String, String>>>> = Rc::new(RefCell::new(None));
457    let slot_for_cb = slot.clone();
458
459    let cancellable = webkit6::gio::Cancellable::new();
460    web_view.evaluate_javascript(
461        js,
462        None,
463        None,
464        Some(&cancellable),
465        move |result| match result {
466            Ok(value) => {
467                let s = value.to_string();
468                *slot_for_cb.borrow_mut() = Some(Ok(s));
469            }
470            Err(err) => {
471                *slot_for_cb.borrow_mut() = Some(Err(err.to_string()));
472            }
473        },
474    );
475
476    let slot_check = slot.clone();
477    let ok = run_loop_until(move || slot_check.borrow().is_some(), budget);
478    if !ok {
479        return Err(EngineError::Timeout {
480            budget,
481            primitive: "eval",
482        });
483    }
484    let result = slot.borrow_mut().take();
485    match result {
486        Some(Ok(s)) => Ok(s),
487        Some(Err(msg)) => Err(EngineError::Other(format!("eval failed: {msg}"))),
488        None => unreachable!(),
489    }
490}
491
492// =============================================================================
493// Inspector capture wiring
494// =============================================================================
495
496fn install_inspector(web_view: &WebView, slots: &super::inspector_bridge::InspectorSlots) -> bool {
497    use super::inspector_bridge::{
498        self, InspectorSlots, NetworkIngestSlot, CONSOLE_HANDLER, NETWORK_HANDLER,
499    };
500
501    if std::env::var_os("VS_DISABLE_INSPECTOR").is_some() {
502        return false;
503    }
504
505    let manager = web_view.user_content_manager().expect(
506        "WebKitGTK WebView is missing a UserContentManager — should not happen on a default WebView",
507    );
508
509    let script = UserScript::new(
510        inspector_bridge::SCRIPT,
511        UserContentInjectedFrames::AllFrames,
512        UserScriptInjectionTime::Start,
513        &[],
514        &[],
515    );
516    manager.add_script(&script);
517
518    if !manager.register_script_message_handler(CONSOLE_HANDLER, None) {
519        return false;
520    }
521    if !manager.register_script_message_handler(NETWORK_HANDLER, None) {
522        return false;
523    }
524
525    let console_slots: InspectorSlots = slots.clone();
526    manager.connect_script_message_received(Some(CONSOLE_HANDLER), move |_, value| {
527        let json = value.to_string();
528        let mut buf = console_slots.console.borrow_mut();
529        inspector_bridge::ingest_console(&mut buf, &json);
530    });
531    let network_slots: InspectorSlots = slots.clone();
532    manager.connect_script_message_received(Some(NETWORK_HANDLER), move |_, value| {
533        let json = value.to_string();
534        let mut entries = network_slots.network.borrow_mut();
535        let mut details = network_slots.details.borrow_mut();
536        let mut pending = network_slots.pending.borrow_mut();
537        inspector_bridge::ingest_network(
538            NetworkIngestSlot {
539                entries: &mut entries,
540                details: &mut details,
541                pending: &mut pending,
542            },
543            &json,
544        );
545    });
546    true
547}
548
549// =============================================================================
550// Host-side cookie save/load via WebKitCookieManager
551// =============================================================================
552//
553// Mirror of the macOS `webkit::cookie_store` module. `document.cookie`
554// can't see or write `HttpOnly` cookies; `WebKitCookieManager` can.
555// The v0.1.2 fix routes auth save/load through this path on every
556// platform.
557mod wpe_cookies {
558    use std::cell::RefCell;
559    use std::rc::Rc;
560    use std::time::Duration;
561
562    use webkit6::gio;
563    use webkit6::glib;
564    use webkit6::prelude::*;
565    use webkit6::soup;
566    use webkit6::WebView;
567
568    use crate::backend::auth::CookieData;
569    use crate::engine::{EngineError, EngineResult};
570
571    use super::run_loop_until;
572
573    const ASYNC_BUDGET: Duration = Duration::from_secs(5);
574
575    fn cookie_manager(web_view: &WebView) -> EngineResult<webkit6::CookieManager> {
576        let session = WebViewExt::network_session(web_view).ok_or_else(|| {
577            EngineError::Other("WebView has no NetworkSession; cookies unavailable".into())
578        })?;
579        session
580            .cookie_manager()
581            .ok_or_else(|| EngineError::Other("NetworkSession has no CookieManager".into()))
582    }
583
584    pub(super) fn get_all_cookies(web_view: &WebView) -> EngineResult<Vec<CookieData>> {
585        let manager = cookie_manager(web_view)?;
586        let slot: Rc<RefCell<Option<Vec<CookieData>>>> = Rc::new(RefCell::new(None));
587        let slot_cb = slot.clone();
588        manager.all_cookies(None::<&gio::Cancellable>, move |result| {
589            let cookies = match result {
590                Ok(v) => v.into_iter().map(|mut c| serialize(&mut c)).collect(),
591                Err(_) => Vec::new(),
592            };
593            *slot_cb.borrow_mut() = Some(cookies);
594        });
595        let check = slot.clone();
596        let ok = run_loop_until(move || check.borrow().is_some(), ASYNC_BUDGET);
597        if !ok {
598            return Err(EngineError::Timeout {
599                budget: ASYNC_BUDGET,
600                primitive: "save_auth (all_cookies)",
601            });
602        }
603        let result = slot.borrow_mut().take().unwrap_or_default();
604        Ok(result)
605    }
606
607    pub(super) fn set_cookies(web_view: &WebView, cookies: &[CookieData]) -> EngineResult<()> {
608        let manager = cookie_manager(web_view)?;
609        for c in cookies {
610            if c.name.is_empty() || c.domain.is_empty() {
611                continue;
612            }
613            let path = if c.path.is_empty() {
614                "/"
615            } else {
616                c.path.as_str()
617            };
618            // max_age=-1: session cookie (no expiration). We set
619            // expires explicitly below if the saved blob carried it.
620            let mut cookie = soup::Cookie::new(&c.name, &c.value, &c.domain, path, -1);
621            cookie.set_secure(c.secure);
622            cookie.set_http_only(c.http_only);
623            if let Some(unix) = c.expires_unix {
624                if let Ok(dt) = glib::DateTime::from_unix_utc(unix) {
625                    cookie.set_expires(&dt);
626                }
627            }
628            if let Some(ss) = c.same_site.as_deref() {
629                let policy = match ss {
630                    "Strict" => soup::SameSitePolicy::Strict,
631                    "None" => soup::SameSitePolicy::None,
632                    _ => soup::SameSitePolicy::Lax,
633                };
634                cookie.set_same_site_policy(policy);
635            }
636
637            let done: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
638            let done_cb = done.clone();
639            manager.add_cookie(&cookie, None::<&gio::Cancellable>, move |_| {
640                *done_cb.borrow_mut() = true;
641            });
642            let check = done.clone();
643            let ok = run_loop_until(move || *check.borrow(), ASYNC_BUDGET);
644            if !ok {
645                return Err(EngineError::Timeout {
646                    budget: ASYNC_BUDGET,
647                    primitive: "load_auth (add_cookie)",
648                });
649            }
650        }
651        Ok(())
652    }
653
654    fn serialize(c: &mut soup::Cookie) -> CookieData {
655        CookieData {
656            name: c.name().map(|s| s.to_string()).unwrap_or_default(),
657            value: c.value().map(|s| s.to_string()).unwrap_or_default(),
658            domain: c.domain().map(|s| s.to_string()).unwrap_or_default(),
659            path: c.path().map(|s| s.to_string()).unwrap_or_default(),
660            expires_unix: c.expires().map(|dt| dt.to_unix()),
661            secure: c.is_secure(),
662            http_only: c.is_http_only(),
663            same_site: match c.same_site_policy() {
664                soup::SameSitePolicy::Strict => Some("Strict".to_string()),
665                soup::SameSitePolicy::None => Some("None".to_string()),
666                soup::SameSitePolicy::Lax => Some("Lax".to_string()),
667                _ => None,
668            },
669        }
670    }
671}