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