git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

src/webview.rs (34K)

  1 //! Servo embedding host: boots an in-process Servo against a software
  2 //! (CPU) rendering context and owns one WebView per tab, all sharing that
  3 //! context — only the active tab is painted and read back (servoshell's
  4 //! model). Finished frames upload into cce-ui's image registry; each tab
  5 //! keeps its last frame so switching is instant.
  6 //!
  7 //! Everything here lives on the main thread. Servo wakes the calloop loop
  8 //! through `Waker` (a channel sender); the app then calls [`ServoHost::pump`],
  9 //! which spins Servo's event loop and, when the delegate has flagged a ready
 10 //! frame on the active tab, paints and reads back pixels. `read_to_image`
 11 //! happens *without* `present()` so the buffer is still there to read.
 12 
 13 use std::cell::{Cell, RefCell};
 14 use std::collections::HashMap;
 15 use std::rc::Rc;
 16 
 17 use dpi::PhysicalSize;
 18 use euclid::Scale;
 19 use servo::{
 20     CreateNewWebViewRequest, DeviceIntRect, DevicePoint, EventLoopWaker, InputEvent,
 21     Key as DomKey, KeyState, KeyboardEvent, LoadStatus, MouseButton as DomMouseButton,
 22     MouseButtonAction, MouseButtonEvent, MouseMoveEvent, NavigationRequest, RenderingContext,
 23     ClipboardDelegate, Code, EditingActionEvent, Location, Modifiers, Servo, ServoBuilder,
 24     SoftwareRenderingContext, StringRequest, Theme,
 25     UserContentManager, WebView, WebViewBuilder, WebViewDelegate, WebViewId, WheelDelta,
 26     WheelEvent, WheelMode,
 27 };
 28 use servo::user_contents::UserStyleSheet;
 29 use servo::protocol_handler::ProtocolRegistry;
 30 use url::Url;
 31 
 32 use crate::downloads::{is_download_url, Downloads};
 33 use crate::pages::{Bookmarks, CceProtocol, Favorites, History};
 34 use crate::Message;
 35 
 36 /// Delegate-observed signals for one webview, polled by the app after each
 37 /// pump.
 38 #[derive(Default)]
 39 struct TabSignals {
 40     frame_ready: bool,
 41     title: Option<String>,
 42     url: Option<Url>,
 43     /// None until Servo reports a load status — the sync must not mistake
 44     /// the default for "finished loading" (that swallows the completion
 45     /// transition history recording depends on).
 46     loading: Option<bool>,
 47 }
 48 
 49 #[derive(Default)]
 50 struct HostShared {
 51     dirty: Cell<bool>,
 52     per: RefCell<HashMap<WebViewId, TabSignals>>,
 53     /// WebViews created by pages (window.open / target=_blank), built in the
 54     /// delegate and adopted as tabs by the next `pump`.
 55     pending_new: RefCell<Vec<WebView>>,
 56     /// A navigation was diverted into a download; the app surfaces the
 57     /// downloads page.
 58     download_started: Cell<bool>,
 59 }
 60 
 61 struct Delegate {
 62     shared: Rc<HostShared>,
 63     wake: calloop::channel::Sender<Message>,
 64     context: Rc<SoftwareRenderingContext>,
 65     downloads: std::sync::Arc<Downloads>,
 66     /// Shared with `ServoHost` so page-opened webviews carry the same user
 67     /// content (the force-dark stylesheet) as the tabs the host builds.
 68     ucm: Rc<UserContentManager>,
 69     /// Handle to this same Rc'd delegate, so page-opened webviews can be
 70     /// delegated back here; filled right after construction.
 71     self_rc: RefCell<std::rc::Weak<Delegate>>,
 72 }
 73 
 74 impl Delegate {
 75     fn with_tab(&self, webview: &WebView, f: impl FnOnce(&mut TabSignals)) {
 76         f(self.shared.per.borrow_mut().entry(webview.id()).or_default());
 77         self.shared.dirty.set(true);
 78         let _ = self.wake.send(Message::Spin);
 79     }
 80 }
 81 
 82 impl WebViewDelegate for Delegate {
 83     fn notify_new_frame_ready(&self, webview: WebView) {
 84         self.with_tab(&webview, |t| t.frame_ready = true);
 85     }
 86 
 87     fn notify_page_title_changed(&self, webview: WebView, title: Option<String>) {
 88         self.with_tab(&webview, |t| t.title = title);
 89     }
 90 
 91     fn notify_url_changed(&self, webview: WebView, url: Url) {
 92         self.with_tab(&webview, |t| t.url = Some(url));
 93     }
 94 
 95     fn notify_load_status_changed(&self, webview: WebView, status: LoadStatus) {
 96         self.with_tab(&webview, |t| t.loading = Some(status != LoadStatus::Complete));
 97     }
 98 
 99     fn request_navigation(&self, _webview: WebView, request: NavigationRequest) {
100         // Navigations to downloadable files become chrome downloads —
101         // Servo has no download path of its own.
102         if is_download_url(&request.url) {
103             let url = request.url.clone();
104             request.deny();
105             self.downloads.start(url);
106             self.shared.download_started.set(true);
107             self.shared.dirty.set(true);
108             let _ = self.wake.send(Message::Spin);
109         } else {
110             request.allow();
111         }
112     }
113 
114     fn request_create_new(&self, _parent_webview: WebView, request: CreateNewWebViewRequest) {
115         let Some(delegate) = self.self_rc.borrow().upgrade() else {
116             return; // dropping the request denies it
117         };
118         let webview = request
119             .builder(self.context.clone())
120             .delegate(delegate)
121             .user_content_manager(self.ucm.clone())
122             .clipboard_delegate(Rc::new(CceClipboard))
123             .build();
124         self.shared.pending_new.borrow_mut().push(webview);
125         self.shared.dirty.set(true);
126         let _ = self.wake.send(Message::Spin);
127     }
128 }
129 
130 /// Force-dark user stylesheet: invert the whole page, then rotate hues back
131 /// so blues stay blue rather than turning orange, and invert media a second
132 /// time so photos and video keep their own colors. This is the crude tier —
133 /// it fights the site's palette rather than asking for its dark theme — but
134 /// it is the only thing that darkens a page like google.com, which serves a
135 /// hardcoded white with no `prefers-color-scheme` rule to honor.
136 ///
137 /// Servo parses user stylesheets with `Origin::User`, where `!important`
138 /// outranks the page's own `!important`, which is what lets these win.
139 const FORCE_DARK_CSS: &str = "\
140 html {
141   background-color: #ffffff !important;
142   filter: invert(1) hue-rotate(180deg) !important;
143 }
144 img, video, picture, canvas, svg, iframe, embed, object,
145 [style*=\"background-image\"], [style*=\"background:url\"] {
146   filter: invert(1) hue-rotate(180deg) !important;
147 }
148 ";
149 
150 /// When to reload pages after the color-scheme setting changes.
151 ///
152 /// TWO reloads, both needed, for two different in-flight changes:
153 ///
154 /// * The constellation hands a new user stylesheet to the script thread as a
155 ///   separate `SetUserContents` message, so a reload issued in the same
156 ///   breath as `add_stylesheet` can rebuild the document before the sheet
157 ///   lands. The first deadline covers that.
158 /// * Force-dark also flips the reported scheme (it reports light, so pages
159 ///   render the light theme the filter then inverts). That notification is
160 ///   likewise asynchronous, and a page reloaded too soon comes back rendered
161 ///   for the OLD scheme — under the filter that means a dark page inverted
162 ///   into a light one, and it stays that way because nothing reloads it
163 ///   again. Measured on google.com: dark -> force-dark reproduces it every
164 ///   time even with a 5s single reload, while the same transition from
165 ///   light -> force-dark (no scheme flip) is correct, and one more reload
166 ///   always settles it. Hence the second deadline.
167 const USER_CONTENT_SETTLE: std::time::Duration = std::time::Duration::from_millis(400);
168 /// Second reload, after any accompanying scheme flip has certainly landed.
169 const SCHEME_SETTLE: std::time::Duration = std::time::Duration::from_millis(2500);
170 
171 /// Page clipboard, routed through the toolkit's wl-copy/wl-paste helpers.
172 ///
173 /// Servo ships an arboard-backed delegate behind its default `clipboard`
174 /// feature, but it lands nothing on the clipboard in this embedding —
175 /// verified by copying in a page and reading the seat's clipboard back,
176 /// which came up empty. Going through `cce_ui`'s helpers also keeps the
177 /// browser on the same clipboard path as the rest of the DE.
178 struct CceClipboard;
179 
180 impl ClipboardDelegate for CceClipboard {
181     fn get_text(&self, _webview: WebView, request: StringRequest) {
182         match cce_ui::widget::clipboard::read_from_clipboard() {
183             Some(text) => request.success(text),
184             None => request.failure("clipboard is empty".into()),
185         }
186     }
187 
188     fn set_text(&self, _webview: WebView, new_contents: String) {
189         cce_ui::widget::clipboard::copy_to_clipboard(&new_contents);
190     }
191 
192     fn clear(&self, _webview: WebView) {
193         cce_ui::widget::clipboard::copy_to_clipboard("");
194     }
195 }
196 
197 /// Wakes the calloop event loop from Servo's internal threads.
198 #[derive(Clone)]
199 struct Waker(calloop::channel::Sender<Message>);
200 
201 impl EventLoopWaker for Waker {
202     fn clone_box(&self) -> Box<dyn EventLoopWaker> {
203         Box::new(self.clone())
204     }
205 
206     fn wake(&self) {
207         let _ = self.0.send(Message::Spin);
208     }
209 }
210 
211 /// One tab: its webview plus the app-visible page state and the last frame
212 /// uploaded to the image registry (id, w px, h px).
213 pub struct Tab {
214     webview: WebView,
215     pub title: Option<String>,
216     pub url: Option<Url>,
217     pub loading: bool,
218     image: Option<(u32, u32, u32)>,
219 }
220 
221 pub struct ServoHost {
222     servo: Servo,
223     context: Rc<SoftwareRenderingContext>,
224     shared: Rc<HostShared>,
225     delegate: Rc<Delegate>,
226     history: std::sync::Arc<History>,
227     bookmarks: std::sync::Arc<Bookmarks>,
228     favorites: std::sync::Arc<Favorites>,
229     tabs: Vec<Tab>,
230     active: usize,
231     size_px: (u32, u32),
232     scale: f32,
233     /// Settings gate for cce://history recording.
234     history_enabled: bool,
235     /// What every webview reports as `prefers-color-scheme`. Held here
236     /// because the theme is per-webview: tabs opened later have to be told.
237     theme: Theme,
238     /// User content shared by every webview; owns the force-dark stylesheet's
239     /// registration and must outlive the webviews (dropping it tells the
240     /// constellation to destroy the manager).
241     ucm: Rc<UserContentManager>,
242     force_dark_sheet: Rc<UserStyleSheet>,
243     force_dark: bool,
244     /// Deadlines for pending reloads, earliest last (popped off the back).
245     reload_at: Vec<std::time::Instant>,
246     /// Raised by the cce://cookies/clear page; acted on here in `pump`.
247     clear_cookies: std::sync::Arc<std::sync::atomic::AtomicBool>,
248     /// Shared with the delegate and the `cce:` handler. The host needs it
249     /// directly because the delegate's sniff cannot see every navigation —
250     /// see [`ServoHost::take_as_download`].
251     downloads: std::sync::Arc<Downloads>,
252 }
253 
254 impl ServoHost {
255     pub fn set_history_enabled(&mut self, on: bool) {
256         self.history_enabled = on;
257     }
258 
259     /// Install or remove the inverting user stylesheet. Servo applies user
260     /// content at page load, so open tabs are reloaded to pick up the change.
261     pub fn set_force_dark(&mut self, on: bool) {
262         if on == self.force_dark {
263             return;
264         }
265         self.force_dark = on;
266         if on {
267             self.ucm.add_stylesheet(self.force_dark_sheet.clone());
268         } else {
269             self.ucm.remove_stylesheet(self.force_dark_sheet.clone());
270         }
271         // Let the change reach the script thread before rebuilding the
272         // documents that have to pick it up. The wake is what guarantees a
273         // pump once the deadline passes: an idle page produces no frames of
274         // its own, so nothing else would turn the loop.
275         let now = std::time::Instant::now();
276         self.reload_at = vec![now + SCHEME_SETTLE, now + USER_CONTENT_SETTLE];
277         // The wakes are what guarantee a pump once each deadline passes: an
278         // idle page produces no frames of its own, so nothing else would turn
279         // the loop.
280         for delay in [USER_CONTENT_SETTLE, SCHEME_SETTLE] {
281             let wake = self.delegate.wake.clone();
282             std::thread::spawn(move || {
283                 std::thread::sleep(delay + std::time::Duration::from_millis(20));
284                 let _ = wake.send(Message::Spin);
285             });
286         }
287     }
288 
289     /// Set the color scheme pages see, now and for tabs opened later.
290     pub fn set_color_scheme(&mut self, theme: Theme) {
291         self.theme = theme;
292         for tab in &self.tabs {
293             tab.webview.notify_theme_change(theme);
294         }
295     }
296 }
297 
298 impl ServoHost {
299     /// `force_dark` is taken up front rather than set afterwards: user content
300     /// only applies at page load, so flipping it later would mean reloading
301     /// the tab that was just opened.
302     pub fn new(
303         wake: calloop::channel::Sender<Message>,
304         url: Url,
305         size_px: (u32, u32),
306         force_dark: bool,
307     ) -> Self {
308         // Servo's TLS stack looks up the process-wide rustls crypto provider.
309         let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
310 
311         let context = Rc::new(
312             SoftwareRenderingContext::new(PhysicalSize::new(size_px.0.max(1), size_px.1.max(1)))
313                 .expect("create software rendering context"),
314         );
315         context
316             .make_current()
317             .expect("make software rendering context current");
318 
319         let history = std::sync::Arc::new(History::load());
320         let bookmarks = std::sync::Arc::new(Bookmarks::load());
321         let favorites = std::sync::Arc::new(Favorites::load());
322         let downloads = std::sync::Arc::new(Downloads::default());
323         let clear_cookies = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
324         let mut protocols = ProtocolRegistry::default();
325         let handler = CceProtocol {
326             history: history.clone(),
327             bookmarks: bookmarks.clone(),
328             favorites: favorites.clone(),
329             downloads: downloads.clone(),
330             clear_cookies: clear_cookies.clone(),
331         };
332         if let Err(e) = protocols.register("cce", handler) {
333             log::error!("failed to register cce: protocol: {e:?}");
334         }
335 
336         // Give Servo somewhere to persist per-profile state. Without a
337         // `config_dir` it keeps the cookie jar in memory only, so every
338         // launch starts logged out of every site; with one it reads and
339         // writes cookie_jar.json (plus the auth cache and HSTS list) there.
340         // Note the jar is plaintext JSON — live sessions for signed-in
341         // accounts sit in it, so it is deliberately under the state dir
342         // rather than anywhere shared or synced.
343         let profile_dir = crate::pages::state_dir().join("profile");
344         if let Err(e) = std::fs::create_dir_all(&profile_dir) {
345             log::warn!("no browser profile dir ({e}); sessions will not persist");
346         } else {
347             // Servo writes the jar 0644. $HOME is 0700 here so that is not
348             // exposed today, but the sessions inside are worth an owner-only
349             // directory of their own rather than relying on that.
350             use std::os::unix::fs::PermissionsExt;
351             let _ = std::fs::set_permissions(&profile_dir, std::fs::Permissions::from_mode(0o700));
352         }
353         // CSS Grid ships disabled in Servo (`layout.grid.enabled` defaults to
354         // false), so every `display: grid` declaration is refused and the
355         // element falls back to block flow — 31 refusals on one mainstream
356         // login page, which is a lot of modern layout quietly dropped.
357         let mut preferences = servo::Preferences::default();
358         preferences.layout_grid_enabled = true;
359         let servo = ServoBuilder::default()
360             .preferences(preferences)
361             .opts(servo::Opts {
362                 config_dir: Some(profile_dir),
363                 ..Default::default()
364             })
365             .event_loop_waker(Box::new(Waker(wake.clone())))
366             .protocol_registry(protocols)
367             .build();
368 
369         let ucm = Rc::new(UserContentManager::new(&servo));
370         let force_dark_sheet = Rc::new(UserStyleSheet::new(
371             FORCE_DARK_CSS.to_string(),
372             Url::parse("cce://force-dark.css").expect("force-dark url"),
373         ));
374         if force_dark {
375             ucm.add_stylesheet(force_dark_sheet.clone());
376         }
377 
378         let shared = Rc::new(HostShared::default());
379         let delegate = Rc::new(Delegate {
380             shared: shared.clone(),
381             wake,
382             context: context.clone(),
383             downloads: downloads.clone(),
384             ucm: ucm.clone(),
385             self_rc: RefCell::new(std::rc::Weak::new()),
386         });
387         *delegate.self_rc.borrow_mut() = Rc::downgrade(&delegate);
388 
389         let mut host = Self {
390             servo,
391             context,
392             shared,
393             delegate,
394             history,
395             bookmarks,
396             favorites,
397             tabs: Vec::new(),
398             // Sentinel so the first open_tab's activate() does the full
399             // show/focus/resize dance instead of early-returning on 0 == 0.
400             active: usize::MAX,
401             size_px,
402             scale: 1.0,
403             history_enabled: true,
404             theme: Theme::Light,
405             ucm,
406             force_dark_sheet,
407             force_dark,
408             reload_at: Vec::new(),
409             clear_cookies,
410             downloads,
411         };
412         // argv can name a download, and the first tab's URL is one of the
413         // navigations the delegate never sees, so it has to be sniffed here.
414         let start = if host.take_as_download(&url) {
415             Url::parse("cce://downloads").expect("downloads url")
416         } else {
417             url
418         };
419         host.open_tab(start);
420         host
421     }
422 
423     /// Take `url` as a download instead of a navigation, if it looks like
424     /// one. Returns whether it was taken.
425     ///
426     /// `WebViewDelegate::request_navigation` — where the sniff normally
427     /// happens — only fires for navigations the *content* starts. A URL the
428     /// embedder supplies never reaches it: not the first tab's (Servo loads
429     /// it straight from `WebViewBuilder::url`), and not one typed in the URL
430     /// bar. Passing an archive URL as argv therefore rendered Servo's
431     /// "Unknown content type (application/octet-stream)" page instead of
432     /// downloading it.
433     ///
434     /// Callers must return without navigating when this returns true, which
435     /// is also what keeps the delegate from starting the same download twice.
436     /// Raising `download_started` (so the app surfaces the downloads page) is
437     /// left to the caller: at startup the first tab opens on that page
438     /// already, and the flag would add a second one — `open_internal_page`
439     /// cannot dedupe against a tab whose URL the delegate has not reported
440     /// yet.
441     fn take_as_download(&self, url: &Url) -> bool {
442         if !is_download_url(url) {
443             return false;
444         }
445         self.downloads.start(url.clone());
446         true
447     }
448 
449     fn build_webview(&self, url: Url) -> WebView {
450         let webview = WebViewBuilder::new(&self.servo, self.context.clone())
451             .url(url)
452             .delegate(self.delegate.clone())
453             .user_content_manager(self.ucm.clone())
454             .clipboard_delegate(Rc::new(CceClipboard))
455             .build();
456         webview.notify_theme_change(self.theme);
457         webview
458     }
459 
460     /// Open a new tab and make it active.
461     pub fn open_tab(&mut self, url: Url) {
462         let webview = self.build_webview(url);
463         self.tabs.push(Tab {
464             webview,
465             title: None,
466             url: None,
467             loading: true,
468             image: None,
469         });
470         self.activate(self.tabs.len() - 1);
471     }
472 
473     /// Close a tab. Returns false when that was the last tab (the app should
474     /// exit; the tab is gone either way).
475     pub fn close_tab(&mut self, index: usize) -> bool {
476         if index >= self.tabs.len() {
477             return true;
478         }
479         let was_active = index == self.active;
480         let old_active = self.active;
481         let tab = self.tabs.remove(index);
482         self.shared.per.borrow_mut().remove(&tab.webview.id());
483         if let Some((id, ..)) = tab.image {
484             cce_ui::vk::free_image(id);
485         }
486         drop(tab); // last WebView handle: servo tears the page down
487         if self.tabs.is_empty() {
488             return false;
489         }
490         // Closing the active tab moves to its neighbor; closing a background
491         // tab keeps the current one (its index may have shifted down).
492         let next = if was_active {
493             index.min(self.tabs.len() - 1)
494         } else if old_active > index {
495             old_active - 1
496         } else {
497             old_active
498         };
499         self.active = usize::MAX; // force activate() to do the work
500         self.activate(next);
501         true
502     }
503 
504     /// Make tab `index` the visible, focused one.
505     pub fn activate(&mut self, index: usize) {
506         if index >= self.tabs.len() || index == self.active {
507             return;
508         }
509         if let Some(old) = self.tabs.get(self.active) {
510             old.webview.blur();
511             old.webview.hide();
512         }
513         self.active = index;
514         let tab = &self.tabs[index];
515         tab.webview.show();
516         tab.webview.focus();
517         tab.webview.set_hidpi_scale_factor(Scale::new(self.scale));
518         tab.webview
519             .resize(PhysicalSize::new(self.size_px.0.max(1), self.size_px.1.max(1)));
520         // Composite whatever frame the tab already has so the switch shows
521         // content immediately; the resize above refreshes it right after.
522         self.paint_active();
523     }
524 
525     pub fn tab_count(&self) -> usize {
526         self.tabs.len()
527     }
528 
529     pub fn active_index(&self) -> usize {
530         self.active
531     }
532 
533     pub fn tab(&self, index: usize) -> Option<&Tab> {
534         self.tabs.get(index)
535     }
536 
537     fn active_tab(&self) -> &Tab {
538         &self.tabs[self.active]
539     }
540 
541     /// Paint the active webview into the shared context and swap the read
542     /// pixels into its registry image.
543     fn paint_active(&mut self) {
544         self.active_tab().webview.paint();
545         let rect = DeviceIntRect::from_size(self.context.size2d().to_i32());
546         if let Some(img) = self.context.read_to_image(rect) {
547             let (w, h) = img.dimensions();
548             let id = cce_ui::vk::upload_rgba(img.into_raw(), w, h);
549             let tab = &mut self.tabs[self.active];
550             if let Some((old, ..)) = tab.image.replace((id, w, h)) {
551                 cce_ui::vk::free_image(old);
552             }
553         }
554     }
555 
556     /// Re-paint the page into a renderer that has just replaced the one the
557     /// tab images were uploaded to.
558     ///
559     /// An image id belongs to a **renderer**, not to the process: `cce-ui`'s
560     /// `window_runner` repairs a lost Wayland transport by opening a new
561     /// session around the same `Application`, which rebuilds the renderer and
562     /// with it the image table, and a draw for an unknown id is skipped
563     /// silently. `paint_active` is the repaint primitive here — the same one
564     /// `activate` uses to show a switched-to tab immediately — so the page
565     /// comes back without a reload.
566     pub fn renderer_replaced(&mut self) {
567         for tab in &mut self.tabs {
568             if let Some((id, ..)) = tab.image.take() {
569                 cce_ui::vk::free_image(id);
570             }
571         }
572         self.paint_active();
573     }
574 
575     /// Spin Servo, sync delegate signals into tabs, and repaint the active
576     /// tab if it produced a frame. Returns (new frame, any state change).
577     pub fn pump(&mut self) -> (bool, bool) {
578         self.servo.spin_event_loop();
579         if self.clear_cookies.swap(false, std::sync::atomic::Ordering::SeqCst) {
580             self.servo.site_data_manager().clear_cookies(None);
581             log::info!("cleared all cookies");
582         }
583         if self.reload_at.last().is_some_and(|at| std::time::Instant::now() >= *at) {
584             self.reload_at.pop();
585             for tab in &self.tabs {
586                 tab.webview.reload();
587             }
588         }
589         // Adopt page-opened webviews as tabs; like a browser popup, the
590         // newest one takes focus.
591         let opened: Vec<WebView> = self.shared.pending_new.borrow_mut().drain(..).collect();
592         for webview in opened {
593             // Built by the delegate, so it has not been told the theme yet.
594             webview.notify_theme_change(self.theme);
595             self.tabs.push(Tab {
596                 webview,
597                 title: None,
598                 url: None,
599                 loading: true,
600                 image: None,
601             });
602             self.activate(self.tabs.len() - 1);
603         }
604         let dirty = self.shared.dirty.take();
605         let mut active_frame = false;
606         if dirty {
607             let mut per = self.shared.per.borrow_mut();
608             for (i, tab) in self.tabs.iter_mut().enumerate() {
609                 if let Some(sig) = per.get_mut(&tab.webview.id()) {
610                     tab.title = sig.title.clone();
611                     tab.url = sig.url.clone();
612                     if let Some(loading) = sig.loading.take() {
613                         let was_loading = tab.loading;
614                         tab.loading = loading;
615                         // Load-complete transition: log the visit.
616                         if was_loading && !loading && self.history_enabled {
617                             if let Some(url) = &tab.url {
618                                 self.history
619                                     .record(url.as_str(), tab.title.as_deref().unwrap_or(""));
620                             }
621                         }
622                     }
623                     if std::mem::take(&mut sig.frame_ready) && i == self.active {
624                         active_frame = true;
625                     }
626                 }
627             }
628         }
629         if active_frame {
630             self.paint_active();
631         }
632         (active_frame, dirty)
633     }
634 
635     pub fn image(&self) -> Option<(u32, u32, u32)> {
636         self.active_tab().image
637     }
638 
639     pub fn title(&self) -> Option<String> {
640         self.active_tab().title.clone()
641     }
642 
643     pub fn url(&self) -> Option<Url> {
644         self.active_tab().url.clone()
645     }
646 
647     pub fn loading(&self) -> bool {
648         self.active_tab().loading
649     }
650 
651     /// A navigation became a download since the last check.
652     pub fn take_download_started(&self) -> bool {
653         self.shared.download_started.take()
654     }
655 
656     /// Whether the active tab's page is bookmarked.
657     pub fn active_bookmarked(&self) -> bool {
658         self.active_tab()
659             .url
660             .as_ref()
661             .is_some_and(|u| self.bookmarks.contains(u.as_str()))
662     }
663 
664     /// Toggle the bookmark for the active tab's page.
665     pub fn toggle_bookmark(&self) {
666         let tab = self.active_tab();
667         if let Some(url) = &tab.url {
668             self.bookmarks
669                 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
670         }
671     }
672 
673     /// The bookmarks store, shared with the `cce://bookmarks` page; the
674     /// chrome's bookmarks menu lists and edits it directly.
675     pub fn bookmarks(&self) -> std::sync::Arc<Bookmarks> {
676         self.bookmarks.clone()
677     }
678 
679     /// The favorites store, shared with the `cce://favorites` page; the
680     /// chrome reads the strip from it.
681     pub fn favorites(&self) -> std::sync::Arc<Favorites> {
682         self.favorites.clone()
683     }
684 
685     /// Whether the active tab's page is in the favorites strip.
686     pub fn active_favorited(&self) -> bool {
687         self.active_tab()
688             .url
689             .as_ref()
690             .is_some_and(|u| self.favorites.contains(u.as_str()))
691     }
692 
693     /// Toggle the favorite for the active tab's page.
694     pub fn toggle_favorite(&self) {
695         let tab = self.active_tab();
696         if let Some(url) = &tab.url {
697             self.favorites
698                 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
699         }
700     }
701 
702     pub fn can_go_back(&self) -> bool {
703         self.active_tab().webview.can_go_back()
704     }
705 
706     pub fn can_go_forward(&self) -> bool {
707         self.active_tab().webview.can_go_forward()
708     }
709 
710     pub fn load(&self, url: Url) {
711         if self.take_as_download(&url) {
712             self.shared.download_started.set(true);
713             return;
714         }
715         self.active_tab().webview.load(url);
716     }
717 
718     pub fn reload(&self) {
719         self.active_tab().webview.reload();
720     }
721 
722     pub fn back(&self) {
723         let webview = &self.active_tab().webview;
724         if webview.can_go_back() {
725             let _ = webview.go_back(1);
726         }
727     }
728 
729     pub fn forward(&self) {
730         let webview = &self.active_tab().webview;
731         if webview.can_go_forward() {
732             let _ = webview.go_forward(1);
733         }
734     }
735 
736     /// Resize the active webview (and the shared rendering context) to a
737     /// physical size. Inactive tabs are brought up to size on activation.
738     pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
739         self.size_px = (width_px, height_px);
740         self.scale = scale;
741         let webview = &self.active_tab().webview;
742         webview.set_hidpi_scale_factor(Scale::new(scale));
743         webview.resize(PhysicalSize::new(width_px.max(1), height_px.max(1)));
744     }
745 
746     /// Pointer position in device pixels relative to the webview origin.
747     pub fn mouse_move(&self, x_px: f32, y_px: f32) {
748         let _ = self.active_tab().webview.notify_input_event(InputEvent::MouseMove(
749             MouseMoveEvent::new(DevicePoint::new(x_px, y_px).into()),
750         ));
751     }
752 
753     pub fn mouse_button(&self, button: DomMouseButton, pressed: bool, x_px: f32, y_px: f32) {
754         let action = if pressed { MouseButtonAction::Down } else { MouseButtonAction::Up };
755         let _ = self.active_tab().webview.notify_input_event(InputEvent::MouseButton(
756             MouseButtonEvent::new(action, button, DevicePoint::new(x_px, y_px).into()),
757         ));
758     }
759 
760     /// Wheel in device pixels, winit sign convention (positive y = scroll
761     /// up). Servo hit-tests the wheel event, lets the page preventDefault,
762     /// and applies the inverted delta as the scroll itself — no separate
763     /// scroll event wanted.
764     pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
765         let _ = self.active_tab().webview.notify_input_event(InputEvent::Wheel(WheelEvent::new(
766             WheelDelta { x: dx_px, y: dy_px, z: 0.0, mode: WheelMode::DeltaPixel },
767             DevicePoint::new(x_px, y_px).into(),
768         )));
769     }
770 
771     /// Clipboard action on the page, in backend-neutral terms.
772     pub fn editing_action_cmd(&self, command: crate::EditingCommand) {
773         self.editing_action(match command {
774             crate::EditingCommand::Copy => EditingActionEvent::Copy,
775             crate::EditingCommand::Cut => EditingActionEvent::Cut,
776             crate::EditingCommand::Paste => EditingActionEvent::Paste,
777         });
778     }
779 
780     /// Pointer button in cce-ui's vocabulary. The Servo mapping lives here
781     /// rather than in `main.rs` so the chrome names no engine's types — the
782     /// WPE backend takes the same arguments.
783     pub fn mouse_button_ui(
784         &self,
785         button: cce_ui::widget::MouseButton,
786         pressed: bool,
787         x_px: f32,
788         y_px: f32,
789     ) {
790         use cce_ui::widget::MouseButton as Ui;
791         let Some(b) = (match button {
792             Ui::Left => Some(DomMouseButton::Left),
793             Ui::Right => Some(DomMouseButton::Right),
794             Ui::Middle => Some(DomMouseButton::Middle),
795             _ => None,
796         }) else {
797             return;
798         };
799         self.mouse_button(b, pressed, x_px, y_px);
800     }
801 
802     /// A cce-ui key event, translated and forwarded. Same signature as the
803     /// WPE backend's `key`.
804     pub fn key_ui(&self, event: &cce_ui::widget::KeyEvent) {
805         use cce_ui::widget::{ElementState as St, Key as UiKey, NamedKey as Nk};
806         let Some(k) = (match &event.logical_key {
807             UiKey::Character(s) => Some(DomKey::Character(s.clone())),
808             UiKey::Named(Nk::Space) => Some(DomKey::Character(" ".into())),
809             UiKey::Named(n) => Some(DomKey::Named(match n {
810                 Nk::Backspace => servo::NamedKey::Backspace,
811                 Nk::Tab => servo::NamedKey::Tab,
812                 Nk::Enter => servo::NamedKey::Enter,
813                 Nk::Escape => servo::NamedKey::Escape,
814                 Nk::ArrowDown => servo::NamedKey::ArrowDown,
815                 Nk::ArrowLeft => servo::NamedKey::ArrowLeft,
816                 Nk::ArrowRight => servo::NamedKey::ArrowRight,
817                 Nk::ArrowUp => servo::NamedKey::ArrowUp,
818                 Nk::End => servo::NamedKey::End,
819                 Nk::Home => servo::NamedKey::Home,
820                 Nk::PageDown => servo::NamedKey::PageDown,
821                 Nk::PageUp => servo::NamedKey::PageUp,
822                 Nk::Delete => servo::NamedKey::Delete,
823                 Nk::Control => servo::NamedKey::Control,
824                 Nk::Shift => servo::NamedKey::Shift,
825                 Nk::Alt => servo::NamedKey::Alt,
826                 Nk::Super => servo::NamedKey::Meta,
827                 Nk::F5 => servo::NamedKey::F5,
828                 Nk::Space => unreachable!("handled above"),
829             })),
830         }) else {
831             return;
832         };
833         let mut modifiers = Modifiers::empty();
834         modifiers.set(Modifiers::CONTROL, event.ctrl);
835         modifiers.set(Modifiers::SHIFT, event.shift);
836         modifiers.set(Modifiers::ALT, event.alt);
837         self.key(k, event.state == St::Pressed, modifiers);
838     }
839 
840     /// Colour scheme in backend-neutral terms: dark or not.
841     pub fn set_color_scheme_dark(&mut self, dark: bool) {
842         self.set_color_scheme(if dark { Theme::Dark } else { Theme::Light });
843     }
844 
845     /// Page focus. A no-op for Servo, which tracks focus itself; present so
846     /// both backends accept the same call.
847     pub fn focus(&self, _focused: bool) {}
848 
849     /// Forward a key to the page, modifiers included.
850     ///
851     /// `from_state_and_key` defaults the modifiers to empty, which delivers
852     /// every chord to the page as a bare character — Ctrl+A typed a literal
853     /// "a" into a focused textarea rather than selecting its contents.
854     pub fn key(&self, key: DomKey, pressed: bool, modifiers: Modifiers) {
855         let state = if pressed { KeyState::Down } else { KeyState::Up };
856         let event = KeyboardEvent::new_without_event(
857             state,
858             key,
859             Code::Unidentified,
860             Location::Standard,
861             modifiers,
862             false,
863             false,
864         );
865         let _ = self
866             .active_tab()
867             .webview
868             .notify_input_event(InputEvent::Keyboard(event));
869     }
870 
871     /// Clipboard action on the page. Servo has no built-in binding for the
872     /// chords — the embedder translates them and the engine then goes
873     /// through the clipboard delegate.
874     pub fn editing_action(&self, action: EditingActionEvent) {
875         let _ = self
876             .active_tab()
877             .webview
878             .notify_input_event(InputEvent::EditingAction(action));
879     }
880 }