teksilo-webview 0.13.1

Embeddable WebView widget for Teksilo — pluggable native engine backend (wry / Servo) behind a Teksilo-native widget.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! `WryBackend` — production engine backend (macOS WKWebView / Windows
//! WebView2 / Linux-X11 WebKitGTK) via the [`wry`] crate.
//!
//! Gated behind the `wry-backend` feature. The native engine subview is
//! created with [`WebViewBuilder::build_as_child`] against the OS parent
//! window handle that the `WebView` widget hands us from its post-mount
//! [`EventContext`](teksilo_core::widget::EventContext) (see
//! `BuildContext::run_after_mount`). Browser events (IPC messages, page-load
//! status, title changes, navigations) are translated into [`WebViewEvent`]s
//! and posted back through the supplied [`AppEventPoster`].
//!
//! Per the plan, this is the macOS / Windows / Linux-X11 backend.
//!
//! **Linux requirements.** WebKitGTK runs on the GTK / GLib main loop and can
//! only embed as a child window under **X11**:
//! - GTK must be initialised before the first webview — `open` calls
//!   `gtk::init()` (idempotent, main-thread) since winit doesn't.
//! - The host must pump the GLib loop each turn or the page never paints — see
//!   [`crate::pump_gtk_events`], driven from `TeksiloAppBuilder::on_loop_tick`.
//! - The parent must be an X11 window. winit 0.30 picks Wayland whenever
//!   `WAYLAND_DISPLAY` is set, handing wry a Wayland handle it can't embed into
//!   ("window handle kind is not supported"). On a Wayland session, run under
//!   XWayland (unset `WAYLAND_DISPLAY` + `GDK_BACKEND=x11` before winit init),
//!   or use the Servo backend.
//!
//! **Engine focus.** wry exposes no focus-changed callback, so the page's own
//! focus is reported by the page: an initialization script forwards `window`'s
//! `focus` / `blur` events over the same IPC channel, prefixed with
//! [`FOCUS_IPC_PREFIX`], and the IPC handler turns those into
//! [`WebViewEvent::EngineFocusChanged`] instead of a user message. A page that
//! posts that exact prefix itself therefore loses the message — the prefix is
//! chosen to make that a deliberate act.
//!
//! **Input pass-through** ([`crate::WebViewInput::Transparent`]) is **not
//! available** here: `wry::WebView`'s whole mutating surface is `set_cookie`,
//! `set_background_color`, `set_bounds` and `set_visible`, none of which touches
//! the native surface's hit region. The call is answered with a
//! [`WebViewEvent::ConsoleMessage`], which is this crate's channel for an
//! operation a backend cannot perform.
//!
//! **Known gaps (tracked):** custom-protocol *handlers* are not yet plumbed
//! through `WebViewAttributes` (only scheme names are carried), so `app://`
//! style local serving is not wired here; `go_back`/`go_forward` are driven
//! via `history.back()/forward()` (wry 0.55 exposes no direct history API).

use std::sync::Arc;

use teksilo_canvas::Rect;
use teksilo_core::AppEventPoster;
use teksilo_core::raw_handle::ParentHandle;
use teksilo_core::window::TeksiloWindowId;

use wry::{PageLoadEvent, WebView, WebViewBuilder};
// Logical positioning is the macOS / Windows path; Linux converts to Physical.
#[cfg(not(target_os = "linux"))]
use wry::dpi::{LogicalPosition, LogicalSize};

use crate::backend::{
    ConsoleLevel, NoopWebViewHandle, WebSource, WebViewAttributes, WebViewBackend, WebViewEvent,
    WebViewHandle, WebViewId, js_string, post_event,
};

/// The IPC message prefix reserved for the page-focus bridge.
///
/// Deliberately unlovely: it has to be a string no application would post by
/// accident, because a message that starts with it is consumed as a focus
/// report rather than delivered to `on_message`.
pub const FOCUS_IPC_PREFIX: &str = "__teksilo_webview_focus:";

/// Initialization script installing the page-focus bridge.
///
/// `window`'s `focus` / `blur` fire when the web content takes and gives up the
/// keyboard, which is the closest thing to an engine focus event wry offers —
/// there is no callback for it on the Rust side.
fn focus_bridge_script() -> String {
    format!(
        "(function(){{\
           var p={prefix};\
           var send=function(v){{try{{window.ipc.postMessage(p+v)}}catch(e){{}}}};\
           window.addEventListener('focus',function(){{send('1')}});\
           window.addEventListener('blur',function(){{send('0')}});\
         }})()",
        prefix = js_string(FOCUS_IPC_PREFIX)
    )
}

/// Production engine backend. Construct and hand to
/// `install_web_view(WryBackend::new())`.
#[derive(Debug, Default)]
pub struct WryBackend {
    _private: (),
}

impl WryBackend {
    /// Construct the wry backend.
    pub fn new() -> Self {
        Self { _private: () }
    }
}

/// Report engine-init failure: drive the widget to its Error chrome via
/// `NavigationFinished { success: false }` (the visual-state signal), plus a
/// console message for diagnostics. Returns a no-op handle so the (now-Some)
/// widget handle stops the open from being retried into the same failure.
fn fail(
    poster: &Option<Arc<dyn AppEventPoster>>,
    window_id: TeksiloWindowId,
    web_view_id: WebViewId,
    text: String,
) -> Box<dyn WebViewHandle> {
    post_event(
        poster,
        window_id,
        web_view_id,
        WebViewEvent::ConsoleMessage {
            level: ConsoleLevel::Error,
            text,
        },
    );
    post_event(
        poster,
        window_id,
        web_view_id,
        WebViewEvent::NavigationFinished {
            url: String::new(),
            success: false,
        },
    );
    Box::new(NoopWebViewHandle)
}

impl WebViewBackend for WryBackend {
    fn open(
        &mut self,
        web_view_id: WebViewId,
        window_id: TeksiloWindowId,
        parent: Option<ParentHandle>,
        attrs: WebViewAttributes,
        poster: Option<Arc<dyn AppEventPoster>>,
    ) -> Box<dyn WebViewHandle> {
        // On Linux, WebKitGTK requires GTK to be initialised before any webview
        // is created (and its GLib loop pumped each turn — see
        // `crate::pump_gtk_events`). winit doesn't init GTK, so do it here. It's
        // idempotent and main-thread-only; `open` always runs on the main thread.
        #[cfg(target_os = "linux")]
        if gtk::init().is_err() {
            return fail(
                &poster,
                window_id,
                web_view_id,
                "WryBackend: gtk::init() failed — WebKitGTK needs GTK initialised".to_string(),
            );
        }

        let Some(parent) = parent else {
            // No OS parent handle — can't create a child subview. Surface the
            // Error state so the widget doesn't sit in Loading forever.
            return fail(
                &poster,
                window_id,
                web_view_id,
                "WryBackend: no parent window handle available; webview not created".to_string(),
            );
        };

        let mut builder = WebViewBuilder::new();

        match &attrs.source {
            Some(WebSource::Url(url)) => builder = builder.with_url(url.clone()),
            Some(WebSource::Html { html, .. }) => builder = builder.with_html(html.clone()),
            None => {}
        }
        if let Some(ua) = &attrs.user_agent {
            builder = builder.with_user_agent(ua.clone());
        }
        builder = builder
            .with_transparent(attrs.transparent)
            .with_devtools(attrs.devtools);

        // --- Browser event handlers → WebViewEvent (each gets its own clone) ---
        builder = builder.with_initialization_script(focus_bridge_script());
        {
            let poster = poster.clone();
            builder = builder.with_ipc_handler(move |req| {
                let body = req.body();
                let event = match body.strip_prefix(FOCUS_IPC_PREFIX) {
                    Some(flag) => WebViewEvent::EngineFocusChanged(flag == "1"),
                    None => WebViewEvent::Message(body.clone()),
                };
                post_event(&poster, window_id, web_view_id, event);
            });
        }
        {
            let poster = poster.clone();
            builder = builder.with_on_page_load_handler(move |event, url| {
                let ev = match event {
                    PageLoadEvent::Started => WebViewEvent::PageLoadStarted,
                    PageLoadEvent::Finished => WebViewEvent::PageLoadFinished,
                };
                post_event(&poster, window_id, web_view_id, ev);
                // Surface a NavigationFinished on load completion so the
                // url/Ready bindings settle even without a separate nav-finish
                // callback in wry 0.55.
                if matches!(event, PageLoadEvent::Finished) {
                    post_event(
                        &poster,
                        window_id,
                        web_view_id,
                        WebViewEvent::NavigationFinished { url, success: true },
                    );
                }
            });
        }
        {
            let poster = poster.clone();
            builder = builder.with_navigation_handler(move |url| {
                post_event(
                    &poster,
                    window_id,
                    web_view_id,
                    WebViewEvent::NavigationStarted {
                        url,
                        can_cancel: false,
                    },
                );
                true // allow — pre-navigation veto is not yet exposed app-side
            });
        }
        {
            let poster = poster.clone();
            builder = builder.with_document_title_changed_handler(move |title| {
                post_event(
                    &poster,
                    window_id,
                    web_view_id,
                    WebViewEvent::TitleChanged(title),
                );
            });
        }
        // Downloads are observed, not steered: the started handler returns
        // `true` (allow) with wry's default destination, because the app's
        // callback runs on a later event-loop tick (events are posted, not
        // delivered inline) and so cannot supply a path synchronously. Apps
        // get start/finish notifications for progress UI / toasts.
        {
            let poster = poster.clone();
            builder = builder.with_download_started_handler(move |url, path| {
                post_event(
                    &poster,
                    window_id,
                    web_view_id,
                    WebViewEvent::DownloadStarted {
                        url,
                        suggested_path: path.clone(),
                    },
                );
                true
            });
        }
        {
            let poster = poster.clone();
            builder = builder.with_download_completed_handler(move |_url, path, success| {
                post_event(
                    &poster,
                    window_id,
                    web_view_id,
                    WebViewEvent::DownloadFinished {
                        path: path.unwrap_or_default(),
                        success,
                    },
                );
            });
        }

        match builder.build_as_child(&parent) {
            Ok(webview) => Box::new(WryHandle {
                webview,
                poster: poster.clone(),
                window_id,
                web_view_id,
            }),
            Err(e) => fail(
                &poster,
                window_id,
                web_view_id,
                format!("WryBackend: failed to create webview: {e}"),
            ),
        }
    }
}

/// Live handle wrapping a `wry::WebView`. `!Send`, but the backend lives in
/// app-state and only ever runs on the main thread, so every call here is
/// main-thread. Dropping the handle drops the `WebView`, tearing the native
/// subview down (RAII).
struct WryHandle {
    webview: WebView,
    /// Kept so the handle can report an operation this engine cannot perform
    /// (see [`WebViewHandle::set_input_passthrough`]) through the same
    /// `ConsoleMessage` channel the open path uses.
    poster: Option<Arc<dyn AppEventPoster>>,
    window_id: TeksiloWindowId,
    web_view_id: WebViewId,
}

impl WebViewHandle for WryHandle {
    fn set_bounds(&self, bounds: Rect, scale_factor: f32) {
        // On Linux the subview is a WebKitGTK child window using integer GDK
        // scaling (GDK_SCALE), which ignores the fractional factor wgpu renders
        // at — so logical bounds land at the wrong place and size. Convert to
        // device pixels (logical × scale) and pass them as Physical so the
        // child lands correctly regardless of GTK's own scale. macOS WKWebView
        // / Windows WebView2 position in logical units and handle DPI
        // themselves, so keep the proven logical path there.
        #[cfg(target_os = "linux")]
        let rect = wry::Rect {
            position: wry::dpi::PhysicalPosition::new(
                (bounds.x * scale_factor) as f64,
                (bounds.y * scale_factor) as f64,
            )
            .into(),
            size: wry::dpi::PhysicalSize::new(
                (bounds.width * scale_factor) as f64,
                (bounds.height * scale_factor) as f64,
            )
            .into(),
        };
        #[cfg(not(target_os = "linux"))]
        let rect = {
            let _ = scale_factor;
            wry::Rect {
                position: LogicalPosition::new(bounds.x, bounds.y).into(),
                size: LogicalSize::new(bounds.width, bounds.height).into(),
            }
        };
        let _ = self.webview.set_bounds(rect);
    }
    fn load_url(&self, url: &str) {
        let _ = self.webview.load_url(url);
    }
    fn load_html(&self, html: &str, _base_url: Option<&str>) {
        // wry 0.55 has no runtime load_html; emulate via document.write so a
        // post-open HTML swap still works.
        let _ = self.webview.evaluate_script(&format!(
            "document.open();document.write({});document.close();",
            js_string(html)
        ));
    }
    fn eval(&self, script: &str) {
        let _ = self.webview.evaluate_script(script);
    }
    fn post_message(&self, msg: &str) {
        // Rust → JS: dispatch a `teksilo-message` event carrying `msg` as data.
        let _ = self.webview.evaluate_script(&format!(
            "window.dispatchEvent(new MessageEvent('teksilo-message',{{data:{}}}))",
            js_string(msg)
        ));
    }
    fn reload(&self) {
        let _ = self.webview.reload();
    }
    fn go_back(&self) {
        let _ = self.webview.evaluate_script("history.back()");
    }
    fn go_forward(&self) {
        let _ = self.webview.evaluate_script("history.forward()");
    }
    fn stop(&self) {
        let _ = self.webview.evaluate_script("window.stop()");
    }
    fn set_visible(&self, visible: bool) {
        let _ = self.webview.set_visible(visible);
    }
    fn set_focus(&self) {
        let _ = self.webview.focus();
    }
    fn set_input_passthrough(&self, passthrough: bool) {
        if !passthrough {
            // Native input is this engine's only mode, so being asked for it is
            // not worth a diagnostic.
            return;
        }
        post_event(
            &self.poster,
            self.window_id,
            self.web_view_id,
            WebViewEvent::ConsoleMessage {
                level: ConsoleLevel::Warn,
                text: "WryBackend: input pass-through is unsupported — this engine \
                       exposes no control over its surface's hit region, so the page \
                       keeps taking presses over its own rectangle"
                    .to_string(),
            },
        );
    }
    fn open_devtools(&self) {
        self.webview.open_devtools();
    }
    fn close_devtools(&self) {
        self.webview.close_devtools();
    }
}