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}
52
53pub 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 #[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 #[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
107fn 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 if !main_ctx.iteration(false) {
120 std::thread::sleep(Duration::from_millis(10));
121 }
122 }
123 done()
124}
125
126use super::common::{parse_snapshot, SNAPSHOT_DOM_WALKER_JS as SNAPSHOT_JS};
131
132impl Engine for WpeBackend {
137 fn open(&mut self, url: &str) -> EngineResult<PageHandle> {
138 let web_view = WebView::new();
139
140 if let Some(settings) = WebViewExt::settings(&web_view) {
144 settings.set_user_agent(Some(crate::engine::DEFAULT_USER_AGENT));
145 }
146
147 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 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 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 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 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 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
451fn 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
492fn 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
549mod 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 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}