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        super::common::run_storage(
381            move |js, budget| eval_js_string(&web_view, js, budget),
382            scope,
383        )
384    }
385
386    fn scripts(&mut self, page: PageHandle) -> EngineResult<Vec<crate::inspector::ScriptEntry>> {
387        let p = self.page_mut(page)?;
388        let web_view = p.web_view.clone();
389        super::common::run_scripts(move |js, budget| eval_js_string(&web_view, js, budget))
390    }
391
392    fn script_source(
393        &mut self,
394        page: PageHandle,
395        seq: u64,
396    ) -> EngineResult<Option<crate::inspector::ScriptSource>> {
397        let p = self.page_mut(page)?;
398        let web_view = p.web_view.clone();
399        super::common::run_script_source(
400            move |js, budget| eval_js_string(&web_view, js, budget),
401            seq,
402        )
403    }
404
405    fn dom(
406        &mut self,
407        page: PageHandle,
408        r: Ref,
409        extra_props: &[String],
410    ) -> EngineResult<Option<crate::inspector::DomDetail>> {
411        let p = self.page_mut(page)?;
412        let web_view = p.web_view.clone();
413        super::common::run_dom(
414            move |js, budget| eval_js_string(&web_view, js, budget),
415            r,
416            extra_props,
417        )
418    }
419
420    fn performance(
421        &mut self,
422        page: PageHandle,
423    ) -> EngineResult<crate::inspector::PerformanceMetrics> {
424        let p = self.page_mut(page)?;
425        let web_view = p.web_view.clone();
426        super::common::run_performance(move |js, budget| eval_js_string(&web_view, js, budget))
427    }
428
429    fn capabilities(&self) -> EngineCapabilities {
430        let any_inspector = self.pages.values().any(|p| p.inspector_installed);
431        EngineCapabilities {
432            renders: true,
433            honors_viewport: true,
434            measures_layout: true,
435            persists_auth: true,
436            inspector_console: any_inspector,
437            inspector_network: any_inspector,
438            name: "wpe",
439            version: "Linux WebKitGTK 6 (webkit6)",
440        }
441    }
442}
443
444// =============================================================================
445// JS evaluation helper
446// =============================================================================
447
448fn eval_js_string(web_view: &WebView, js: &str, budget: Duration) -> EngineResult<String> {
449    let slot: Rc<RefCell<Option<Result<String, String>>>> = Rc::new(RefCell::new(None));
450    let slot_for_cb = slot.clone();
451
452    let cancellable = webkit6::gio::Cancellable::new();
453    web_view.evaluate_javascript(
454        js,
455        None,
456        None,
457        Some(&cancellable),
458        move |result| match result {
459            Ok(value) => {
460                let s = value.to_string();
461                *slot_for_cb.borrow_mut() = Some(Ok(s));
462            }
463            Err(err) => {
464                *slot_for_cb.borrow_mut() = Some(Err(err.to_string()));
465            }
466        },
467    );
468
469    let slot_check = slot.clone();
470    let ok = run_loop_until(move || slot_check.borrow().is_some(), budget);
471    if !ok {
472        return Err(EngineError::Timeout {
473            budget,
474            primitive: "eval",
475        });
476    }
477    let result = slot.borrow_mut().take();
478    match result {
479        Some(Ok(s)) => Ok(s),
480        Some(Err(msg)) => Err(EngineError::Other(format!("eval failed: {msg}"))),
481        None => unreachable!(),
482    }
483}
484
485// =============================================================================
486// Inspector capture wiring
487// =============================================================================
488
489fn install_inspector(web_view: &WebView, slots: &super::inspector_bridge::InspectorSlots) -> bool {
490    use super::inspector_bridge::{
491        self, InspectorSlots, NetworkIngestSlot, CONSOLE_HANDLER, NETWORK_HANDLER,
492    };
493
494    if std::env::var_os("VS_DISABLE_INSPECTOR").is_some() {
495        return false;
496    }
497
498    let manager = web_view.user_content_manager().expect(
499        "WebKitGTK WebView is missing a UserContentManager — should not happen on a default WebView",
500    );
501
502    let script = UserScript::new(
503        inspector_bridge::SCRIPT,
504        UserContentInjectedFrames::AllFrames,
505        UserScriptInjectionTime::Start,
506        &[],
507        &[],
508    );
509    manager.add_script(&script);
510
511    if !manager.register_script_message_handler(CONSOLE_HANDLER, None) {
512        return false;
513    }
514    if !manager.register_script_message_handler(NETWORK_HANDLER, None) {
515        return false;
516    }
517
518    let console_slots: InspectorSlots = slots.clone();
519    manager.connect_script_message_received(Some(CONSOLE_HANDLER), move |_, value| {
520        let json = value.to_string();
521        let mut buf = console_slots.console.borrow_mut();
522        inspector_bridge::ingest_console(&mut buf, &json);
523    });
524    let network_slots: InspectorSlots = slots.clone();
525    manager.connect_script_message_received(Some(NETWORK_HANDLER), move |_, value| {
526        let json = value.to_string();
527        let mut entries = network_slots.network.borrow_mut();
528        let mut details = network_slots.details.borrow_mut();
529        let mut pending = network_slots.pending.borrow_mut();
530        inspector_bridge::ingest_network(
531            NetworkIngestSlot {
532                entries: &mut entries,
533                details: &mut details,
534                pending: &mut pending,
535            },
536            &json,
537        );
538    });
539    true
540}
541
542// =============================================================================
543// Host-side cookie save/load via WebKitCookieManager
544// =============================================================================
545//
546// Mirror of the macOS `webkit::cookie_store` module. `document.cookie`
547// can't see or write `HttpOnly` cookies; `WebKitCookieManager` can.
548// The v0.1.2 fix routes auth save/load through this path on every
549// platform.
550mod wpe_cookies {
551    use std::cell::RefCell;
552    use std::rc::Rc;
553    use std::time::Duration;
554
555    use webkit6::gio;
556    use webkit6::glib;
557    use webkit6::prelude::*;
558    use webkit6::soup;
559    use webkit6::WebView;
560
561    use crate::backend::auth::CookieData;
562    use crate::engine::{EngineError, EngineResult};
563
564    use super::run_loop_until;
565
566    const ASYNC_BUDGET: Duration = Duration::from_secs(5);
567
568    fn cookie_manager(web_view: &WebView) -> EngineResult<webkit6::CookieManager> {
569        let session = WebViewExt::network_session(web_view).ok_or_else(|| {
570            EngineError::Other("WebView has no NetworkSession; cookies unavailable".into())
571        })?;
572        session
573            .cookie_manager()
574            .ok_or_else(|| EngineError::Other("NetworkSession has no CookieManager".into()))
575    }
576
577    pub(super) fn get_all_cookies(web_view: &WebView) -> EngineResult<Vec<CookieData>> {
578        let manager = cookie_manager(web_view)?;
579        let slot: Rc<RefCell<Option<Vec<CookieData>>>> = Rc::new(RefCell::new(None));
580        let slot_cb = slot.clone();
581        manager.all_cookies(None::<&gio::Cancellable>, move |result| {
582            let cookies = match result {
583                Ok(v) => v.into_iter().map(|mut c| serialize(&mut c)).collect(),
584                Err(_) => Vec::new(),
585            };
586            *slot_cb.borrow_mut() = Some(cookies);
587        });
588        let check = slot.clone();
589        let ok = run_loop_until(move || check.borrow().is_some(), ASYNC_BUDGET);
590        if !ok {
591            return Err(EngineError::Timeout {
592                budget: ASYNC_BUDGET,
593                primitive: "save_auth (all_cookies)",
594            });
595        }
596        let result = slot.borrow_mut().take().unwrap_or_default();
597        Ok(result)
598    }
599
600    pub(super) fn set_cookies(web_view: &WebView, cookies: &[CookieData]) -> EngineResult<()> {
601        let manager = cookie_manager(web_view)?;
602        for c in cookies {
603            if c.name.is_empty() || c.domain.is_empty() {
604                continue;
605            }
606            let path = if c.path.is_empty() {
607                "/"
608            } else {
609                c.path.as_str()
610            };
611            // max_age=-1: session cookie (no expiration). We set
612            // expires explicitly below if the saved blob carried it.
613            let mut cookie = soup::Cookie::new(&c.name, &c.value, &c.domain, path, -1);
614            cookie.set_secure(c.secure);
615            cookie.set_http_only(c.http_only);
616            if let Some(unix) = c.expires_unix {
617                if let Ok(dt) = glib::DateTime::from_unix_utc(unix) {
618                    cookie.set_expires(&dt);
619                }
620            }
621            if let Some(ss) = c.same_site.as_deref() {
622                let policy = match ss {
623                    "Strict" => soup::SameSitePolicy::Strict,
624                    "None" => soup::SameSitePolicy::None,
625                    _ => soup::SameSitePolicy::Lax,
626                };
627                cookie.set_same_site_policy(policy);
628            }
629
630            let done: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
631            let done_cb = done.clone();
632            manager.add_cookie(&cookie, None::<&gio::Cancellable>, move |_| {
633                *done_cb.borrow_mut() = true;
634            });
635            let check = done.clone();
636            let ok = run_loop_until(move || *check.borrow(), ASYNC_BUDGET);
637            if !ok {
638                return Err(EngineError::Timeout {
639                    budget: ASYNC_BUDGET,
640                    primitive: "load_auth (add_cookie)",
641                });
642            }
643        }
644        Ok(())
645    }
646
647    fn serialize(c: &mut soup::Cookie) -> CookieData {
648        CookieData {
649            name: c.name().map(|s| s.to_string()).unwrap_or_default(),
650            value: c.value().map(|s| s.to_string()).unwrap_or_default(),
651            domain: c.domain().map(|s| s.to_string()).unwrap_or_default(),
652            path: c.path().map(|s| s.to_string()).unwrap_or_default(),
653            expires_unix: c.expires().map(|dt| dt.to_unix()),
654            secure: c.is_secure(),
655            http_only: c.is_http_only(),
656            same_site: match c.same_site_policy() {
657                soup::SameSitePolicy::Strict => Some("Strict".to_string()),
658                soup::SameSitePolicy::None => Some("None".to_string()),
659                soup::SameSitePolicy::Lax => Some("Lax".to_string()),
660                _ => None,
661            },
662        }
663    }
664}