1use 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
40struct WpePage {
45 web_view: WebView,
46 inspector: super::inspector_bridge::InspectorSlots,
47 inspector_installed: bool,
51 cookie_baseline: std::cell::RefCell<Option<Vec<super::auth::CookieData>>>,
52 cookie_next_seq: std::cell::RefCell<u64>,
53}
54
55pub 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 #[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 #[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
109fn 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 if !main_ctx.iteration(false) {
122 std::thread::sleep(Duration::from_millis(10));
123 }
124 }
125 done()
126}
127
128use super::common::{parse_snapshot, SNAPSHOT_DOM_WALKER_JS as SNAPSHOT_JS};
133
134impl Engine for WpeBackend {
139 fn open(&mut self, url: &str) -> EngineResult<PageHandle> {
140 let web_view = WebView::new();
141
142 if let Some(settings) = WebViewExt::settings(&web_view) {
146 settings.set_user_agent(Some(crate::engine::DEFAULT_USER_AGENT));
147 }
148
149 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 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 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 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 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 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(), ¤t, &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
470fn 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
511fn 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
568mod 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 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}