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

src/webview.rs (34.9K)

  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     /// Open a new tab behind the active one, which stays shown and focused.
474     pub fn open_background_tab(&mut self, url: Url) {
475         let back = self.active;
476         self.open_tab(url);
477         self.activate(back);
478     }
479 
480     /// Close a tab. Returns false when that was the last tab (the app should
481     /// exit; the tab is gone either way).
482     pub fn close_tab(&mut self, index: usize) -> bool {
483         if index >= self.tabs.len() {
484             return true;
485         }
486         let was_active = index == self.active;
487         let old_active = self.active;
488         let tab = self.tabs.remove(index);
489         self.shared.per.borrow_mut().remove(&tab.webview.id());
490         if let Some((id, ..)) = tab.image {
491             cce_ui::vk::free_image(id);
492         }
493         drop(tab); // last WebView handle: servo tears the page down
494         if self.tabs.is_empty() {
495             return false;
496         }
497         // Closing the active tab moves to its neighbor; closing a background
498         // tab keeps the current one (its index may have shifted down).
499         let next = if was_active {
500             index.min(self.tabs.len() - 1)
501         } else if old_active > index {
502             old_active - 1
503         } else {
504             old_active
505         };
506         self.active = usize::MAX; // force activate() to do the work
507         self.activate(next);
508         true
509     }
510 
511     /// Make tab `index` the visible, focused one.
512     pub fn activate(&mut self, index: usize) {
513         if index >= self.tabs.len() || index == self.active {
514             return;
515         }
516         if let Some(old) = self.tabs.get(self.active) {
517             old.webview.blur();
518             old.webview.hide();
519         }
520         self.active = index;
521         let tab = &self.tabs[index];
522         tab.webview.show();
523         tab.webview.focus();
524         tab.webview.set_hidpi_scale_factor(Scale::new(self.scale));
525         tab.webview
526             .resize(PhysicalSize::new(self.size_px.0.max(1), self.size_px.1.max(1)));
527         // Composite whatever frame the tab already has so the switch shows
528         // content immediately; the resize above refreshes it right after.
529         self.paint_active();
530     }
531 
532     pub fn tab_count(&self) -> usize {
533         self.tabs.len()
534     }
535 
536     pub fn active_index(&self) -> usize {
537         self.active
538     }
539 
540     pub fn tab(&self, index: usize) -> Option<&Tab> {
541         self.tabs.get(index)
542     }
543 
544     fn active_tab(&self) -> &Tab {
545         &self.tabs[self.active]
546     }
547 
548     /// Paint the active webview into the shared context and swap the read
549     /// pixels into its registry image.
550     fn paint_active(&mut self) {
551         self.active_tab().webview.paint();
552         let rect = DeviceIntRect::from_size(self.context.size2d().to_i32());
553         if let Some(img) = self.context.read_to_image(rect) {
554             let (w, h) = img.dimensions();
555             let id = cce_ui::vk::upload_rgba(img.into_raw(), w, h);
556             let tab = &mut self.tabs[self.active];
557             if let Some((old, ..)) = tab.image.replace((id, w, h)) {
558                 cce_ui::vk::free_image(old);
559             }
560         }
561     }
562 
563     /// Re-paint the page into a renderer that has just replaced the one the
564     /// tab images were uploaded to.
565     ///
566     /// An image id belongs to a **renderer**, not to the process: `cce-ui`'s
567     /// `window_runner` repairs a lost Wayland transport by opening a new
568     /// session around the same `Application`, which rebuilds the renderer and
569     /// with it the image table, and a draw for an unknown id is skipped
570     /// silently. `paint_active` is the repaint primitive here — the same one
571     /// `activate` uses to show a switched-to tab immediately — so the page
572     /// comes back without a reload.
573     pub fn renderer_replaced(&mut self) {
574         for tab in &mut self.tabs {
575             if let Some((id, ..)) = tab.image.take() {
576                 cce_ui::vk::free_image(id);
577             }
578         }
579         self.paint_active();
580     }
581 
582     /// Spin Servo, sync delegate signals into tabs, and repaint the active
583     /// tab if it produced a frame. Returns (new frame, any state change).
584     pub fn pump(&mut self) -> (bool, bool) {
585         self.servo.spin_event_loop();
586         if self.clear_cookies.swap(false, std::sync::atomic::Ordering::SeqCst) {
587             self.servo.site_data_manager().clear_cookies(None);
588             log::info!("cleared all cookies");
589         }
590         if self.reload_at.last().is_some_and(|at| std::time::Instant::now() >= *at) {
591             self.reload_at.pop();
592             for tab in &self.tabs {
593                 tab.webview.reload();
594             }
595         }
596         // Adopt page-opened webviews as tabs; like a browser popup, the
597         // newest one takes focus.
598         let opened: Vec<WebView> = self.shared.pending_new.borrow_mut().drain(..).collect();
599         for webview in opened {
600             // Built by the delegate, so it has not been told the theme yet.
601             webview.notify_theme_change(self.theme);
602             self.tabs.push(Tab {
603                 webview,
604                 title: None,
605                 url: None,
606                 loading: true,
607                 image: None,
608             });
609             self.activate(self.tabs.len() - 1);
610         }
611         let dirty = self.shared.dirty.take();
612         let mut active_frame = false;
613         if dirty {
614             let mut per = self.shared.per.borrow_mut();
615             for (i, tab) in self.tabs.iter_mut().enumerate() {
616                 if let Some(sig) = per.get_mut(&tab.webview.id()) {
617                     tab.title = sig.title.clone();
618                     tab.url = sig.url.clone();
619                     if let Some(loading) = sig.loading.take() {
620                         let was_loading = tab.loading;
621                         tab.loading = loading;
622                         // Load-complete transition: log the visit.
623                         if was_loading && !loading && self.history_enabled {
624                             if let Some(url) = &tab.url {
625                                 self.history
626                                     .record(url.as_str(), tab.title.as_deref().unwrap_or(""));
627                             }
628                         }
629                     }
630                     if std::mem::take(&mut sig.frame_ready) && i == self.active {
631                         active_frame = true;
632                     }
633                 }
634             }
635         }
636         if active_frame {
637             self.paint_active();
638         }
639         (active_frame, dirty)
640     }
641 
642     pub fn image(&self) -> Option<(u32, u32, u32)> {
643         self.active_tab().image
644     }
645 
646     pub fn title(&self) -> Option<String> {
647         self.active_tab().title.clone()
648     }
649 
650     pub fn url(&self) -> Option<Url> {
651         self.active_tab().url.clone()
652     }
653 
654     pub fn loading(&self) -> bool {
655         self.active_tab().loading
656     }
657 
658     /// A navigation became a download since the last check.
659     pub fn take_download_started(&self) -> bool {
660         self.shared.download_started.take()
661     }
662 
663     /// Whether the active tab's page is bookmarked.
664     pub fn active_bookmarked(&self) -> bool {
665         self.active_tab()
666             .url
667             .as_ref()
668             .is_some_and(|u| self.bookmarks.contains(u.as_str()))
669     }
670 
671     /// Toggle the bookmark for the active tab's page.
672     pub fn toggle_bookmark(&self) {
673         let tab = self.active_tab();
674         if let Some(url) = &tab.url {
675             self.bookmarks
676                 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
677         }
678     }
679 
680     /// The bookmarks store, shared with the `cce://bookmarks` page; the
681     /// chrome's bookmarks menu lists and edits it directly.
682     pub fn bookmarks(&self) -> std::sync::Arc<Bookmarks> {
683         self.bookmarks.clone()
684     }
685 
686     /// The favorites store, shared with the `cce://favorites` page; the
687     /// chrome reads the strip from it.
688     pub fn favorites(&self) -> std::sync::Arc<Favorites> {
689         self.favorites.clone()
690     }
691 
692     /// Whether the active tab's page is in the favorites strip.
693     pub fn active_favorited(&self) -> bool {
694         self.active_tab()
695             .url
696             .as_ref()
697             .is_some_and(|u| self.favorites.contains(u.as_str()))
698     }
699 
700     /// Toggle the favorite for the active tab's page.
701     pub fn toggle_favorite(&self) {
702         let tab = self.active_tab();
703         if let Some(url) = &tab.url {
704             self.favorites
705                 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
706         }
707     }
708 
709     pub fn can_go_back(&self) -> bool {
710         self.active_tab().webview.can_go_back()
711     }
712 
713     pub fn can_go_forward(&self) -> bool {
714         self.active_tab().webview.can_go_forward()
715     }
716 
717     pub fn load(&self, url: Url) {
718         if self.take_as_download(&url) {
719             self.shared.download_started.set(true);
720             return;
721         }
722         self.active_tab().webview.load(url);
723     }
724 
725     pub fn reload(&self) {
726         self.active_tab().webview.reload();
727     }
728 
729     pub fn back(&self) {
730         let webview = &self.active_tab().webview;
731         if webview.can_go_back() {
732             let _ = webview.go_back(1);
733         }
734     }
735 
736     pub fn forward(&self) {
737         let webview = &self.active_tab().webview;
738         if webview.can_go_forward() {
739             let _ = webview.go_forward(1);
740         }
741     }
742 
743     /// Resize the active webview (and the shared rendering context) to a
744     /// physical size. Inactive tabs are brought up to size on activation.
745     pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
746         self.size_px = (width_px, height_px);
747         self.scale = scale;
748         let webview = &self.active_tab().webview;
749         webview.set_hidpi_scale_factor(Scale::new(scale));
750         webview.resize(PhysicalSize::new(width_px.max(1), height_px.max(1)));
751     }
752 
753     /// Pointer position in device pixels relative to the webview origin.
754     pub fn mouse_move(&self, x_px: f32, y_px: f32) {
755         let _ = self.active_tab().webview.notify_input_event(InputEvent::MouseMove(
756             MouseMoveEvent::new(DevicePoint::new(x_px, y_px).into()),
757         ));
758     }
759 
760     pub fn mouse_button(&self, button: DomMouseButton, pressed: bool, x_px: f32, y_px: f32) {
761         let action = if pressed { MouseButtonAction::Down } else { MouseButtonAction::Up };
762         let _ = self.active_tab().webview.notify_input_event(InputEvent::MouseButton(
763             MouseButtonEvent::new(action, button, DevicePoint::new(x_px, y_px).into()),
764         ));
765     }
766 
767     /// Wheel in device pixels, winit sign convention (positive y = scroll
768     /// up). Servo hit-tests the wheel event, lets the page preventDefault,
769     /// and applies the inverted delta as the scroll itself — no separate
770     /// scroll event wanted.
771     pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
772         let _ = self.active_tab().webview.notify_input_event(InputEvent::Wheel(WheelEvent::new(
773             WheelDelta { x: dx_px, y: dy_px, z: 0.0, mode: WheelMode::DeltaPixel },
774             DevicePoint::new(x_px, y_px).into(),
775         )));
776     }
777 
778     /// Clipboard action on the page, in backend-neutral terms.
779     pub fn editing_action_cmd(&self, command: crate::EditingCommand) {
780         self.editing_action(match command {
781             crate::EditingCommand::Copy => EditingActionEvent::Copy,
782             crate::EditingCommand::Cut => EditingActionEvent::Cut,
783             crate::EditingCommand::Paste => EditingActionEvent::Paste,
784         });
785     }
786 
787     /// Pointer button in cce-ui's vocabulary. The Servo mapping lives here
788     /// rather than in `main.rs` so the chrome names no engine's types — the
789     /// WPE backend takes the same arguments.
790     pub fn mouse_button_ui(
791         &self,
792         button: cce_ui::widget::MouseButton,
793         pressed: bool,
794         x_px: f32,
795         y_px: f32,
796     ) {
797         use cce_ui::widget::MouseButton as Ui;
798         let Some(b) = (match button {
799             Ui::Left => Some(DomMouseButton::Left),
800             Ui::Right => Some(DomMouseButton::Right),
801             Ui::Middle => Some(DomMouseButton::Middle),
802             _ => None,
803         }) else {
804             return;
805         };
806         self.mouse_button(b, pressed, x_px, y_px);
807     }
808 
809     // ---- vi mode ----
810     //
811     // The WPE host's surface, as no-ops: Servo has no script worlds to run
812     // the focus watcher or the hint script in, and no find controller. Keys
813     // still bind; hints, `gi`, half-page scrolls and search do nothing.
814 
815     pub fn set_vi_enabled(&mut self, _on: bool) {}
816     pub fn take_vi_focus(&self) -> Option<bool> {
817         None
818     }
819     pub fn vi_eval(&self, _script: &str, _tag: u32) {}
820     pub fn take_vi_result(&self) -> Option<(u32, String)> {
821         None
822     }
823     pub fn find(&self, _text: &str, _backwards: bool) {}
824     pub fn find_next(&self) {}
825     pub fn find_prev(&self) {}
826     pub fn find_finish(&self) {}
827     pub fn take_find_result(&self) -> Option<u32> {
828         None
829     }
830 
831     /// A cce-ui key event, translated and forwarded. Same signature as the
832     /// WPE backend's `key`.
833     pub fn key_ui(&self, event: &cce_ui::widget::KeyEvent) {
834         use cce_ui::widget::{ElementState as St, Key as UiKey, NamedKey as Nk};
835         let Some(k) = (match &event.logical_key {
836             UiKey::Character(s) => Some(DomKey::Character(s.clone())),
837             UiKey::Named(Nk::Space) => Some(DomKey::Character(" ".into())),
838             UiKey::Named(n) => Some(DomKey::Named(match n {
839                 Nk::Backspace => servo::NamedKey::Backspace,
840                 Nk::Tab => servo::NamedKey::Tab,
841                 Nk::Enter => servo::NamedKey::Enter,
842                 Nk::Escape => servo::NamedKey::Escape,
843                 Nk::ArrowDown => servo::NamedKey::ArrowDown,
844                 Nk::ArrowLeft => servo::NamedKey::ArrowLeft,
845                 Nk::ArrowRight => servo::NamedKey::ArrowRight,
846                 Nk::ArrowUp => servo::NamedKey::ArrowUp,
847                 Nk::End => servo::NamedKey::End,
848                 Nk::Home => servo::NamedKey::Home,
849                 Nk::PageDown => servo::NamedKey::PageDown,
850                 Nk::PageUp => servo::NamedKey::PageUp,
851                 Nk::Delete => servo::NamedKey::Delete,
852                 Nk::Control => servo::NamedKey::Control,
853                 Nk::Shift => servo::NamedKey::Shift,
854                 Nk::Alt => servo::NamedKey::Alt,
855                 Nk::Super => servo::NamedKey::Meta,
856                 Nk::F5 => servo::NamedKey::F5,
857                 Nk::Space => unreachable!("handled above"),
858             })),
859         }) else {
860             return;
861         };
862         let mut modifiers = Modifiers::empty();
863         modifiers.set(Modifiers::CONTROL, event.ctrl);
864         modifiers.set(Modifiers::SHIFT, event.shift);
865         modifiers.set(Modifiers::ALT, event.alt);
866         self.key(k, event.state == St::Pressed, modifiers);
867     }
868 
869     /// Colour scheme in backend-neutral terms: dark or not.
870     pub fn set_color_scheme_dark(&mut self, dark: bool) {
871         self.set_color_scheme(if dark { Theme::Dark } else { Theme::Light });
872     }
873 
874     /// Page focus. A no-op for Servo, which tracks focus itself; present so
875     /// both backends accept the same call.
876     pub fn focus(&mut self, _focused: bool) {}
877 
878     /// Forward a key to the page, modifiers included.
879     ///
880     /// `from_state_and_key` defaults the modifiers to empty, which delivers
881     /// every chord to the page as a bare character — Ctrl+A typed a literal
882     /// "a" into a focused textarea rather than selecting its contents.
883     pub fn key(&self, key: DomKey, pressed: bool, modifiers: Modifiers) {
884         let state = if pressed { KeyState::Down } else { KeyState::Up };
885         let event = KeyboardEvent::new_without_event(
886             state,
887             key,
888             Code::Unidentified,
889             Location::Standard,
890             modifiers,
891             false,
892             false,
893         );
894         let _ = self
895             .active_tab()
896             .webview
897             .notify_input_event(InputEvent::Keyboard(event));
898     }
899 
900     /// Clipboard action on the page. Servo has no built-in binding for the
901     /// chords — the embedder translates them and the engine then goes
902     /// through the clipboard delegate.
903     pub fn editing_action(&self, action: EditingActionEvent) {
904         let _ = self
905             .active_tab()
906             .webview
907             .notify_input_event(InputEvent::EditingAction(action));
908     }
909 }