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 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
444fn 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
485fn 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
542mod 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 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}