teksilo_webview/backend.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Backend abstraction for [`WebView`](crate::WebView).
5//!
6//! A web view is the one widget that cannot render into Teksilo's wgpu
7//! surface — every realistic engine (WKWebView, WebView2, WebKitGTK, Servo)
8//! owns its own rendering and lives as a native subview *on top of* the wgpu
9//! pass. This module mirrors the established platform-backend pattern
10//! (`FileDialogBackend` /
11//! `ExternalDndBackend`): a swappable [`WebViewBackend`] trait creates an
12//! engine-specific [`WebViewHandle`], and a per-app [`WebViewRegistry`]
13//! (registered in app-state) owns the backend and routes JS→Rust /
14//! browser-lifecycle events back into the originating widget tree.
15//!
16//! The default build ships only the [`MemoryWebViewBackend`] (headless,
17//! deterministic). The native `wry` / `servo` backends live behind the
18//! `wry-backend` / `servo-backend` features.
19
20use std::cell::{Cell, RefCell};
21use std::collections::HashMap;
22use std::path::PathBuf;
23use std::rc::Rc;
24use std::sync::Arc;
25use std::sync::atomic::{AtomicU64, Ordering};
26
27use teksilo_canvas::Rect;
28use teksilo_core::AppEventPoster;
29use teksilo_core::raw_handle::ParentHandle;
30use teksilo_core::widget::EventContext;
31use teksilo_core::window::TeksiloWindowId;
32
33/// Process-unique identity for a single web view instance. Allocated once at
34/// `WebView` construction and stable across rebuilds, so backend events route
35/// to the correct widget. Same shape as `MenuItemId`.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct WebViewId(u64);
38
39impl WebViewId {
40 /// Allocate the next process-unique id.
41 pub fn next() -> Self {
42 static COUNTER: AtomicU64 = AtomicU64::new(1);
43 Self(COUNTER.fetch_add(1, Ordering::Relaxed))
44 }
45
46 /// The raw numeric value (diagnostics / map keys).
47 pub fn raw(self) -> u64 {
48 self.0
49 }
50}
51
52/// What a web view should initially display.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum WebSource {
55 /// Navigate to a URL.
56 Url(String),
57 /// Load an inline HTML string, with an optional base URL for relative
58 /// asset resolution.
59 Html {
60 html: String,
61 base_url: Option<String>,
62 },
63}
64
65/// Severity of a [`WebViewEvent::ConsoleMessage`].
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ConsoleLevel {
68 Log,
69 Warn,
70 Error,
71}
72
73/// Engine configuration accumulated by the [`WebView`](crate::WebView)
74/// builders and handed to [`WebViewBackend::open`].
75#[derive(Debug, Clone, Default)]
76pub struct WebViewAttributes {
77 /// Initial content. `None` means "blank page".
78 pub source: Option<WebSource>,
79 /// Override the engine's `User-Agent`.
80 pub user_agent: Option<String>,
81 /// Transparent engine background (compose over Teksilo content).
82 pub transparent: bool,
83 /// Enable the engine's devtools (debug builds only by convention).
84 pub devtools: bool,
85 /// Custom-protocol scheme names the app wants to serve (`app` → `app://`).
86 /// The dispatch closures live app-side; the backend only needs the names
87 /// at open time to register the schemes.
88 pub custom_protocols: Vec<String>,
89}
90
91/// A live native engine subview. Dropping the handle tears the subview down
92/// (RAII, same contract as `ExternalDndGuard`).
93///
94/// All methods are `&self` — the handle is cheaply shareable and the engine
95/// state lives behind the platform's own interior mutability.
96pub trait WebViewHandle: 'static {
97 /// Reposition / resize the native subview within its parent window.
98 /// `bounds` is in **logical** pixels (Teksilo's coordinate system);
99 /// `scale_factor` is the host window's HiDPI scale. Most engines position
100 /// in logical units, but some need device pixels (`bounds × scale_factor`)
101 /// because their own toolkit runs at a different scale than the wgpu
102 /// surface — notably WebKitGTK on X11/XWayland, which uses integer GDK
103 /// scaling and ignores fractional factors. Issued whenever the widget's
104 /// layout bounds or the window scale change.
105 fn set_bounds(&self, bounds: Rect, scale_factor: f32);
106 /// Navigate to a URL.
107 fn load_url(&self, url: &str);
108 /// Load inline HTML.
109 fn load_html(&self, html: &str, base_url: Option<&str>);
110 /// Evaluate JavaScript in the page.
111 fn eval(&self, script: &str);
112 /// Rust → JS: dispatch a `teksilo-message` `MessageEvent` carrying `msg`.
113 fn post_message(&self, msg: &str);
114 /// Reload the current page.
115 fn reload(&self);
116 /// Navigate back in history.
117 fn go_back(&self);
118 /// Navigate forward in history.
119 fn go_forward(&self);
120 /// Stop the current load.
121 fn stop(&self);
122 /// Show / hide the native subview. **Load-bearing**: a native subview
123 /// lives outside the wgpu pass, so framework dormancy (a `Switcher`
124 /// parking the page) does NOT hide it — the `WebView` widget bridges
125 /// its activation signal to this call. See `WebView`'s rustdoc.
126 fn set_visible(&self, visible: bool);
127 /// Give the engine subview keyboard focus.
128 fn set_focus(&self);
129 /// Open the engine's developer tools (no-op on backends that don't
130 /// expose them — Servo's embedding API has no clean devtools hook today).
131 fn open_devtools(&self) {}
132 /// Close the engine's developer tools (no-op where unsupported).
133 fn close_devtools(&self) {}
134}
135
136/// A browser lifecycle / JS→Rust event surfaced by a backend.
137#[derive(Debug, Clone)]
138pub enum WebViewEvent {
139 /// A navigation is starting. `can_cancel` is true on backends that
140 /// support pre-navigation veto.
141 NavigationStarted { url: String, can_cancel: bool },
142 /// A navigation finished (or failed).
143 NavigationFinished { url: String, success: bool },
144 /// The page began loading resources.
145 PageLoadStarted,
146 /// The page finished loading.
147 PageLoadFinished,
148 /// The document title changed.
149 TitleChanged(String),
150 /// `window.ipc.postMessage(payload)` fired in the page.
151 Message(String),
152 /// A download began.
153 DownloadStarted {
154 url: String,
155 suggested_path: PathBuf,
156 },
157 /// A download finished (or failed).
158 DownloadFinished { path: PathBuf, success: bool },
159 /// A console message (forwarded in debug builds / by best-effort
160 /// backends to report unsupported operations).
161 ConsoleMessage { level: ConsoleLevel, text: String },
162}
163
164/// Boxed inside `AppEvent::External` when a backend produces an event.
165/// `teksilo-app`'s app-event handler downcasts to this type and routes to
166/// [`WebViewRegistry::deliver`]. Mirrors `FileDialogEventPayload`.
167pub struct WebViewEventPayload {
168 /// The window the web view lives in — routes delivery to the right tree.
169 pub window_id_owner: TeksiloWindowId,
170 /// Which web view the event belongs to.
171 pub web_view_id: WebViewId,
172 /// The event itself.
173 pub event: WebViewEvent,
174}
175
176/// Post a [`WebViewEvent`] back to the UI loop, if a poster is available.
177/// Shared by every engine backend so the emit path lives in one place.
178#[allow(dead_code)] // used only by the feature-gated engine backends
179pub(crate) fn post_event(
180 poster: &Option<Arc<dyn AppEventPoster>>,
181 window_id: TeksiloWindowId,
182 web_view_id: WebViewId,
183 event: WebViewEvent,
184) {
185 if let Some(poster) = poster {
186 let payload = WebViewEventPayload {
187 window_id_owner: window_id,
188 web_view_id,
189 event,
190 };
191 poster.post_external(Box::new(payload) as Box<dyn std::any::Any + Send>);
192 }
193}
194
195/// Encode `s` as a JavaScript string literal (double-quoted, fully escaped) so
196/// it can be safely interpolated into an `evaluate_script` body. Lives in the
197/// shared backend module (not in one engine's file) so every JS-executing
198/// backend uses the same audited escaper — an incomplete escape is a JS
199/// injection / silent-SyntaxError hazard.
200///
201/// Escapes the JS-significant characters: `"`, `\`, the C0 controls (incl.
202/// `\n` / `\r` / `\t`), and U+2028 / U+2029 (LINE / PARAGRAPH SEPARATOR — these
203/// are line terminators *inside* JS string literals pre-ES2019 and silently
204/// break the literal otherwise).
205#[allow(dead_code)] // used only by the feature-gated engine backends
206pub(crate) fn js_string(s: &str) -> String {
207 let mut out = String::with_capacity(s.len() + 2);
208 out.push('"');
209 for c in s.chars() {
210 match c {
211 '"' => out.push_str("\\\""),
212 '\\' => out.push_str("\\\\"),
213 '\n' => out.push_str("\\n"),
214 '\r' => out.push_str("\\r"),
215 '\t' => out.push_str("\\t"),
216 '\u{2028}' => out.push_str("\\u2028"),
217 '\u{2029}' => out.push_str("\\u2029"),
218 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
219 c => out.push(c),
220 }
221 }
222 out.push('"');
223 out
224}
225
226/// Swappable web-view engine backend.
227///
228/// The real backends (`WryBackend` / `ServoBackend`, behind their features)
229/// create a native engine subview parented to the host window. The test
230/// backend ([`MemoryWebViewBackend`]) records calls and synthesizes events.
231pub trait WebViewBackend {
232 /// Create a native engine subview for `web_view_id`, parented to
233 /// `window_id`'s OS window. The backend MUST deliver browser events by
234 /// calling [`AppEventPoster::post_external`] on `poster` with a boxed
235 /// [`WebViewEventPayload`] whose `web_view_id` / `window_id_owner` match.
236 ///
237 /// `parent` is `None` when the host context can't surface an OS handle
238 /// (headless tests, or a build-time open before the window-ops sink is
239 /// available); native backends treat `None` as "defer until a handle
240 /// arrives" rather than failing hard.
241 fn open(
242 &mut self,
243 web_view_id: WebViewId,
244 window_id: TeksiloWindowId,
245 parent: Option<ParentHandle>,
246 attrs: WebViewAttributes,
247 poster: Option<Arc<dyn AppEventPoster>>,
248 ) -> Box<dyn WebViewHandle>;
249}
250
251// ============================================================
252// WebViewRegistry — per-app service (app-state)
253// ============================================================
254
255/// Callback the `WebView` widget installs to receive its own backend events.
256type EventCallback = Box<dyn FnMut(WebViewEvent, &mut EventContext)>;
257
258struct Registered {
259 window_id: TeksiloWindowId,
260 callback: EventCallback,
261}
262
263struct RegistryState {
264 backend: RefCell<Box<dyn WebViewBackend>>,
265 callbacks: RefCell<HashMap<WebViewId, Registered>>,
266 /// Bumped per `open`, purely for diagnostics.
267 open_count: Cell<u64>,
268 /// The `(web_view_id, window)` of the delivery currently in flight, if any
269 /// (`deliver` removes the callback, runs it, then reinserts). If a
270 /// re-entrant `unregister`/`purge_window` hits this id/window while the
271 /// callback runs, `delivery_aborted` is set and `deliver` skips the
272 /// reinsert — so a since-purged callback is never resurrected even if
273 /// widget teardown becomes synchronous. `deliver` is not itself re-entrant
274 /// (backend events are posted, not delivered inline), so a single slot
275 /// suffices.
276 delivering: Cell<Option<(WebViewId, TeksiloWindowId)>>,
277 delivery_aborted: Cell<bool>,
278}
279
280/// Per-app web-view service. Registered in app-state by
281/// `TeksiloAppBuilderWebViewExt::install_web_view` (in the `teksilo` umbrella
282/// crate); reachable from any `build()` / handler via
283/// `ctx.app_state::<WebViewRegistry>()`. Cloneable; clones share the same
284/// backend and event-callback map.
285#[derive(Clone)]
286pub struct WebViewRegistry {
287 inner: Rc<RegistryState>,
288}
289
290impl WebViewRegistry {
291 /// Build a registry wrapping `backend`.
292 pub fn new<B: WebViewBackend + 'static>(backend: B) -> Self {
293 Self {
294 inner: Rc::new(RegistryState {
295 backend: RefCell::new(Box::new(backend)),
296 callbacks: RefCell::new(HashMap::new()),
297 open_count: Cell::new(0),
298 delivering: Cell::new(None),
299 delivery_aborted: Cell::new(false),
300 }),
301 }
302 }
303
304 /// Open a native subview and register the widget's event callback in one
305 /// step. Returns the live [`WebViewHandle`] (dropped on widget removal).
306 pub fn open(
307 &self,
308 web_view_id: WebViewId,
309 window_id: TeksiloWindowId,
310 parent: Option<ParentHandle>,
311 attrs: WebViewAttributes,
312 poster: Option<Arc<dyn AppEventPoster>>,
313 on_event: impl FnMut(WebViewEvent, &mut EventContext) + 'static,
314 ) -> Box<dyn WebViewHandle> {
315 self.inner
316 .open_count
317 .set(self.inner.open_count.get().wrapping_add(1));
318 self.inner.callbacks.borrow_mut().insert(
319 web_view_id,
320 Registered {
321 window_id,
322 callback: Box::new(on_event),
323 },
324 );
325 self.inner
326 .backend
327 .borrow_mut()
328 .open(web_view_id, window_id, parent, attrs, poster)
329 }
330
331 /// Route a backend-produced payload to its registered widget callback.
332 /// Called by `teksilo-app` from the `AppEvent::External` arm. Dropped
333 /// silently if the callback was already purged (window/widget gone).
334 pub fn deliver(&self, payload: WebViewEventPayload, ctx: &mut EventContext) {
335 // Take the callback out so the map borrow isn't held while the
336 // (re-entrant-capable) callback runs — it may itself open another web
337 // view, which inserts. Then put it back via `or_insert`, so a *newer*
338 // registration created during the callback wins and is not clobbered.
339 //
340 // The `delivering` slot guards the one remaining hazard: if the
341 // callback synchronously tears the widget/window down (a re-entrant
342 // `unregister` / `purge_window` for this id/window), we must NOT
343 // resurrect the dead callback. That marks `delivery_aborted`, and we
344 // skip the reinsert below. (Today teardown is deferred so this never
345 // fires, but the guard makes the invariant hold unconditionally.)
346 let entry = self
347 .inner
348 .callbacks
349 .borrow_mut()
350 .remove(&payload.web_view_id);
351 let Some(mut reg) = entry else {
352 return;
353 };
354 if reg.window_id != payload.window_id_owner {
355 // Stale routing — drop, don't reinsert.
356 return;
357 }
358 self.inner
359 .delivering
360 .set(Some((payload.web_view_id, reg.window_id)));
361 self.inner.delivery_aborted.set(false);
362
363 (reg.callback)(payload.event, ctx);
364
365 self.inner.delivering.set(None);
366 if !self.inner.delivery_aborted.get() {
367 self.inner
368 .callbacks
369 .borrow_mut()
370 .entry(payload.web_view_id)
371 .or_insert(reg);
372 }
373 }
374
375 /// Drop the registration for a single web view (widget removed).
376 pub fn unregister(&self, web_view_id: WebViewId) {
377 self.inner.callbacks.borrow_mut().remove(&web_view_id);
378 if matches!(self.inner.delivering.get(), Some((id, _)) if id == web_view_id) {
379 self.inner.delivery_aborted.set(true);
380 }
381 }
382
383 /// Drop every registration owned by `window_id`. Called by
384 /// `teksilo-app`'s window-close path so callbacks capturing widget state
385 /// cannot fire into a torn-down tree. Mirrors
386 /// `FileDialogHandle::purge_window`.
387 pub fn purge_window(&self, window_id: TeksiloWindowId) {
388 self.inner
389 .callbacks
390 .borrow_mut()
391 .retain(|_, r| r.window_id != window_id);
392 if matches!(self.inner.delivering.get(), Some((_, win)) if win == window_id) {
393 self.inner.delivery_aborted.set(true);
394 }
395 }
396
397 /// Number of registered web views. Test helper.
398 pub fn registered_count(&self) -> usize {
399 self.inner.callbacks.borrow().len()
400 }
401}
402
403impl std::fmt::Debug for WebViewRegistry {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 f.debug_struct("WebViewRegistry")
406 .field("registered", &self.inner.callbacks.borrow().len())
407 .field("opens", &self.inner.open_count.get())
408 .finish_non_exhaustive()
409 }
410}
411
412// ============================================================
413// MemoryWebViewBackend (headless test backend)
414// ============================================================
415
416/// One recorded backend operation. Lets tests assert the exact call sequence
417/// (open → set_bounds → set_visible(false) → set_visible(true) → …) without a
418/// real engine, window, or GPU.
419#[derive(Debug, Clone, PartialEq)]
420pub enum WebViewOp {
421 Open {
422 web_view_id: WebViewId,
423 },
424 SetBounds {
425 web_view_id: WebViewId,
426 bounds: Rect,
427 },
428 LoadUrl {
429 web_view_id: WebViewId,
430 url: String,
431 },
432 LoadHtml {
433 web_view_id: WebViewId,
434 },
435 Eval {
436 web_view_id: WebViewId,
437 script: String,
438 },
439 PostMessage {
440 web_view_id: WebViewId,
441 msg: String,
442 },
443 Reload {
444 web_view_id: WebViewId,
445 },
446 GoBack {
447 web_view_id: WebViewId,
448 },
449 GoForward {
450 web_view_id: WebViewId,
451 },
452 Stop {
453 web_view_id: WebViewId,
454 },
455 SetVisible {
456 web_view_id: WebViewId,
457 visible: bool,
458 },
459 SetFocus {
460 web_view_id: WebViewId,
461 },
462 OpenDevtools {
463 web_view_id: WebViewId,
464 },
465 CloseDevtools {
466 web_view_id: WebViewId,
467 },
468 Dropped {
469 web_view_id: WebViewId,
470 },
471}
472
473/// Shared, cloneable recorder. Both the backend and the test hold a clone, so
474/// the test can read the op log after driving the tree.
475#[derive(Clone, Default)]
476pub struct MemoryWebViewRecords {
477 ops: Rc<RefCell<Vec<WebViewOp>>>,
478}
479
480impl MemoryWebViewRecords {
481 /// All recorded ops, in order.
482 pub fn ops(&self) -> Vec<WebViewOp> {
483 self.ops.borrow().clone()
484 }
485
486 /// Every op for a given web view.
487 pub fn ops_for(&self, id: WebViewId) -> Vec<WebViewOp> {
488 self.ops
489 .borrow()
490 .iter()
491 .filter(|op| op_web_view_id(op) == id)
492 .cloned()
493 .collect()
494 }
495
496 /// The ordered `set_visible` booleans for a web view — the headline
497 /// dormancy assertion (`[false, true]` across a tab-away / tab-back).
498 pub fn visibility_log(&self, id: WebViewId) -> Vec<bool> {
499 self.ops
500 .borrow()
501 .iter()
502 .filter_map(|op| match op {
503 WebViewOp::SetVisible {
504 web_view_id,
505 visible,
506 } if *web_view_id == id => Some(*visible),
507 _ => None,
508 })
509 .collect()
510 }
511
512 fn push(&self, op: WebViewOp) {
513 self.ops.borrow_mut().push(op);
514 }
515}
516
517fn op_web_view_id(op: &WebViewOp) -> WebViewId {
518 match op {
519 WebViewOp::Open { web_view_id }
520 | WebViewOp::SetBounds { web_view_id, .. }
521 | WebViewOp::LoadUrl { web_view_id, .. }
522 | WebViewOp::LoadHtml { web_view_id }
523 | WebViewOp::Eval { web_view_id, .. }
524 | WebViewOp::PostMessage { web_view_id, .. }
525 | WebViewOp::Reload { web_view_id }
526 | WebViewOp::GoBack { web_view_id }
527 | WebViewOp::GoForward { web_view_id }
528 | WebViewOp::Stop { web_view_id }
529 | WebViewOp::SetVisible { web_view_id, .. }
530 | WebViewOp::SetFocus { web_view_id }
531 | WebViewOp::OpenDevtools { web_view_id }
532 | WebViewOp::CloseDevtools { web_view_id }
533 | WebViewOp::Dropped { web_view_id } => *web_view_id,
534 }
535}
536
537/// In-memory deterministic backend for headless tests. Records every op into a
538/// shared [`MemoryWebViewRecords`]; never renders. Mirrors `MemoryFileDialog`.
539pub struct MemoryWebViewBackend {
540 records: MemoryWebViewRecords,
541}
542
543impl MemoryWebViewBackend {
544 /// Build a backend plus its shared recorder; clone the returned records
545 /// before moving the backend into a [`WebViewRegistry`].
546 pub fn new() -> (Self, MemoryWebViewRecords) {
547 let records = MemoryWebViewRecords::default();
548 (
549 Self {
550 records: records.clone(),
551 },
552 records,
553 )
554 }
555}
556
557struct MemoryWebViewHandle {
558 web_view_id: WebViewId,
559 records: MemoryWebViewRecords,
560}
561
562impl WebViewHandle for MemoryWebViewHandle {
563 fn set_bounds(&self, bounds: Rect, _scale_factor: f32) {
564 // Record logical bounds (scale-independent) so test assertions stay
565 // resolution-agnostic.
566 self.records.push(WebViewOp::SetBounds {
567 web_view_id: self.web_view_id,
568 bounds,
569 });
570 }
571 fn load_url(&self, url: &str) {
572 self.records.push(WebViewOp::LoadUrl {
573 web_view_id: self.web_view_id,
574 url: url.to_string(),
575 });
576 }
577 fn load_html(&self, _html: &str, _base_url: Option<&str>) {
578 self.records.push(WebViewOp::LoadHtml {
579 web_view_id: self.web_view_id,
580 });
581 }
582 fn eval(&self, script: &str) {
583 self.records.push(WebViewOp::Eval {
584 web_view_id: self.web_view_id,
585 script: script.to_string(),
586 });
587 }
588 fn post_message(&self, msg: &str) {
589 self.records.push(WebViewOp::PostMessage {
590 web_view_id: self.web_view_id,
591 msg: msg.to_string(),
592 });
593 }
594 fn reload(&self) {
595 self.records.push(WebViewOp::Reload {
596 web_view_id: self.web_view_id,
597 });
598 }
599 fn go_back(&self) {
600 self.records.push(WebViewOp::GoBack {
601 web_view_id: self.web_view_id,
602 });
603 }
604 fn go_forward(&self) {
605 self.records.push(WebViewOp::GoForward {
606 web_view_id: self.web_view_id,
607 });
608 }
609 fn stop(&self) {
610 self.records.push(WebViewOp::Stop {
611 web_view_id: self.web_view_id,
612 });
613 }
614 fn set_visible(&self, visible: bool) {
615 self.records.push(WebViewOp::SetVisible {
616 web_view_id: self.web_view_id,
617 visible,
618 });
619 }
620 fn set_focus(&self) {
621 self.records.push(WebViewOp::SetFocus {
622 web_view_id: self.web_view_id,
623 });
624 }
625 fn open_devtools(&self) {
626 self.records.push(WebViewOp::OpenDevtools {
627 web_view_id: self.web_view_id,
628 });
629 }
630 fn close_devtools(&self) {
631 self.records.push(WebViewOp::CloseDevtools {
632 web_view_id: self.web_view_id,
633 });
634 }
635}
636
637impl Drop for MemoryWebViewHandle {
638 fn drop(&mut self) {
639 self.records.push(WebViewOp::Dropped {
640 web_view_id: self.web_view_id,
641 });
642 }
643}
644
645impl WebViewBackend for MemoryWebViewBackend {
646 fn open(
647 &mut self,
648 web_view_id: WebViewId,
649 _window_id: TeksiloWindowId,
650 _parent: Option<ParentHandle>,
651 attrs: WebViewAttributes,
652 _poster: Option<Arc<dyn AppEventPoster>>,
653 ) -> Box<dyn WebViewHandle> {
654 self.records.push(WebViewOp::Open { web_view_id });
655 // Replay the initial source as the corresponding load op so tests can
656 // see what the widget asked to display.
657 match attrs.source {
658 Some(WebSource::Url(url)) => self.records.push(WebViewOp::LoadUrl { web_view_id, url }),
659 Some(WebSource::Html { .. }) => self.records.push(WebViewOp::LoadHtml { web_view_id }),
660 None => {}
661 }
662 Box::new(MemoryWebViewHandle {
663 web_view_id,
664 records: self.records.clone(),
665 })
666 }
667}
668
669/// Convenience: a registry backed by a fresh [`MemoryWebViewBackend`], plus
670/// its shared recorder. The one-liner headless-test setup.
671pub fn memory_registry() -> (WebViewRegistry, MemoryWebViewRecords) {
672 let (backend, records) = MemoryWebViewBackend::new();
673 (WebViewRegistry::new(backend), records)
674}
675
676/// A backend that renders nothing and records nothing — every call is a no-op.
677///
678/// Unlike [`MemoryWebViewBackend`] (which accumulates an unbounded op log for
679/// test assertions), this is safe to install in a long-running app as the
680/// placeholder default until a native engine backend is wired. Used by
681/// `install_web_view_default`.
682#[derive(Debug, Default)]
683pub struct NoopWebViewBackend;
684
685/// A [`WebViewHandle`] whose every method is a no-op. Returned by
686/// [`NoopWebViewBackend`], and by the `WryBackend` / `ServoBackend` engine
687/// backends on their failure paths (no parent handle, engine-init error) so a
688/// failed open still yields a live, harmless handle. Defined once so a method
689/// added to the trait is implemented in exactly one place. `pub(crate)` —
690/// backends return it boxed; apps never name it.
691pub(crate) struct NoopWebViewHandle;
692
693impl WebViewHandle for NoopWebViewHandle {
694 fn set_bounds(&self, _bounds: Rect, _scale_factor: f32) {}
695 fn load_url(&self, _url: &str) {}
696 fn load_html(&self, _html: &str, _base_url: Option<&str>) {}
697 fn eval(&self, _script: &str) {}
698 fn post_message(&self, _msg: &str) {}
699 fn reload(&self) {}
700 fn go_back(&self) {}
701 fn go_forward(&self) {}
702 fn stop(&self) {}
703 fn set_visible(&self, _visible: bool) {}
704 fn set_focus(&self) {}
705}
706
707impl WebViewBackend for NoopWebViewBackend {
708 fn open(
709 &mut self,
710 _web_view_id: WebViewId,
711 _window_id: TeksiloWindowId,
712 _parent: Option<ParentHandle>,
713 _attrs: WebViewAttributes,
714 _poster: Option<Arc<dyn AppEventPoster>>,
715 ) -> Box<dyn WebViewHandle> {
716 Box::new(NoopWebViewHandle)
717 }
718}