Skip to main content

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}