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

src/wpe/host.rs (64.3K)

   1 //! `WebKitHost` — the WPE-backed twin of `webview.rs`'s `ServoHost`.
   2 //!
   3 //! Deliberately mirrors that type's method surface so `main.rs` can switch
   4 //! engines by changing a type name rather than its logic. Frames land in
   5 //! cce-ui's image registry exactly as before, so `display_list` is unchanged:
   6 //! the page is still one full-bleed quad.
   7 //!
   8 //! **Loop integration is the one real difference.** Servo had an
   9 //! `EventLoopWaker` that pushed `Message::Spin` into calloop from its own
  10 //! threads; WPE runs on a GLib `GMainContext`. [`WebKitHost::pump`] therefore
  11 //! drains that context non-blockingly, which keeps the same shape as
  12 //! `ServoHost::pump` but means *something has to call it*. Today that is the
  13 //! app's `tick`. The correct fix is to put the context's pollfds into calloop
  14 //! so the app wakes only when GLib has work — see WPE-PORT.md; doing it by
  15 //! polling first keeps this milestone about the engine, not the event loop.
  16 
  17 use std::cell::{Cell, RefCell};
  18 use std::ffi::{c_char, c_void, CString};
  19 use std::rc::Rc;
  20 
  21 use url::Url;
  22 
  23 use cce_ui::widget::{KeyEvent, MouseButton};
  24 
  25 use super::ffi::*;
  26 use super::glib_source::GlibPoll;
  27 use super::input;
  28 use super::subclass::{types, FRAME_SINK};
  29 
  30 /// Page state a tab's WebKit signals write into.
  31 ///
  32 /// Held behind an `Rc` because each connected signal owns a reference: the
  33 /// closure outlives any borrow we could hand it, and the webview may emit
  34 /// after the `Tab` has moved within `tabs` (a `Vec` reallocates).
  35 #[derive(Default)]
  36 struct TabState {
  37     title: RefCell<Option<String>>,
  38     url: RefCell<Option<Url>>,
  39     loading: Cell<bool>,
  40     /// Set by any signal, cleared by `pump`. This is what lets a *background*
  41     /// tab report a title change — the old polling only ever looked at the
  42     /// active webview.
  43     dirty: Cell<bool>,
  44 }
  45 
  46 /// One tab: its webview plus the app-visible page state and the last frame
  47 /// uploaded to the image registry (id, w px, h px). Same shape as
  48 /// `webview::Tab` so the chrome reads it identically.
  49 pub struct Tab {
  50     webview: *mut WebKitWebView,
  51     view: *mut WPEView,
  52     state: Rc<TabState>,
  53     pub title: Option<String>,
  54     pub url: Option<Url>,
  55     pub loading: bool,
  56     image: Option<(u32, u32, u32)>,
  57 }
  58 
  59 impl Drop for Tab {
  60     fn drop(&mut self) {
  61         // Unref the webview *first*: destroying it runs the closures'
  62         // destroy-notify, which releases their `Rc<TabState>` refs. Dropping
  63         // the state before the object that can still emit into it would be a
  64         // use-after-free.
  65         unsafe { g_object_unref(self.webview as *mut _) };
  66         if let Some((id, ..)) = self.image {
  67             cce_ui::vk::free_image(id);
  68         }
  69     }
  70 }
  71 
  72 /// `notify::` handler shared by title / uri / is-loading: read the property
  73 /// straight back off the emitting webview and stash it.
  74 unsafe extern "C" fn on_notify(
  75     obj: *mut GObject,
  76     _pspec: *mut GParamSpec,
  77     data: gpointer,
  78 ) {
  79     let st = &*(data as *const TabState);
  80     let wv = obj as *mut WebKitWebView;
  81     *st.title.borrow_mut() = from_cstr(webkit_web_view_get_title(wv));
  82     if let Some(u) = from_cstr(webkit_web_view_get_uri(wv)).and_then(|u| Url::parse(&u).ok()) {
  83         *st.url.borrow_mut() = Some(u);
  84     }
  85     st.loading.set(webkit_web_view_is_loading(wv) != 0);
  86     st.dirty.set(true);
  87 }
  88 
  89 /// Releases the `Rc` ref a connection owned, when the closure is destroyed.
  90 unsafe extern "C" fn drop_state_ref(data: gpointer, _closure: *mut GClosure) {
  91     drop(Rc::from_raw(data as *const TabState));
  92 }
  93 
  94 unsafe fn connect_notify(wv: *mut WebKitWebView, signal: &str, state: &Rc<TabState>) {
  95     let name = cstr(signal);
  96     // Each connection owns its own ref, handed back by `drop_state_ref`.
  97     let raw = Rc::into_raw(state.clone()) as gpointer;
  98     g_signal_connect_data(
  99         wv as *mut _,
 100         name.as_ptr(),
 101         Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
 102             on_notify as unsafe extern "C" fn(*mut GObject, *mut GParamSpec, gpointer),
 103         )),
 104         raw,
 105         Some(drop_state_ref),
 106         0,
 107     );
 108 }
 109 
 110 /// The frame handed over by `render_buffer`, drained by `pump`. A slot, not a
 111 /// queue: only the newest frame is ever shown, and the engine will not run far
 112 /// ahead of a browser that has not released the one it is holding.
 113 /// Counters behind `CCE_BROWSER_FRAME_DEBUG=1`: how many frames the engine
 114 /// finished against how many were actually read back. The gap between them is
 115 /// what pacing saves, and it is invisible from the outside — a browser that
 116 /// skips nine frames in ten looks exactly like one that copies all ten.
 117 #[derive(Default)]
 118 struct FrameCounts {
 119     produced: u64,
 120     read: u64,
 121 }
 122 
 123 fn frame_debug() -> bool {
 124     static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
 125     *ON.get_or_init(|| std::env::var_os("CCE_BROWSER_FRAME_DEBUG").is_some())
 126 }
 127 
 128 #[derive(Default)]
 129 struct Pending {
 130     /// The newest finished buffer the engine has handed over, still unread.
 131     /// Read and released at the next `pump`; superseded by a newer one, which
 132     /// hands this one back **unread** — that skipped copy is the whole point
 133     /// of holding it rather than copying in the callback.
 134     held: Option<(*mut WPEView, *mut WPEBuffer)>,
 135     counts: FrameCounts,
 136     /// When the counters were last reported.
 137     reported: Option<std::time::Instant>,
 138 }
 139 
 140 pub struct WebKitHost {
 141     display: *mut WPEDisplay,
 142     toplevel: *mut WPEToplevel,
 143     tabs: Vec<Tab>,
 144     active: usize,
 145     size_px: (u32, u32),
 146     scale: f32,
 147     pending: Rc<std::cell::RefCell<Pending>>,
 148     /// GLib's pollfd set, mirrored into one epoll fd for calloop.
 149     poll: Option<GlibPoll>,
 150     /// Shared with the `cce:` pages, exactly as `ServoHost` holds them —
 151     /// bookmarks and history are app state, not engine state, so they cross
 152     /// the backend swap unchanged.
 153     history: std::sync::Arc<crate::pages::History>,
 154     bookmarks: std::sync::Arc<crate::pages::Bookmarks>,
 155     favorites: std::sync::Arc<crate::pages::Favorites>,
 156     history_enabled: bool,
 157     force_dark: bool,
 158     /// Serves the `cce:` pages. Boxed and leaked into the scheme callback,
 159     /// so it must outlive every webview.
 160     protocol: Rc<crate::pages::CceProtocol>,
 161     downloads: std::sync::Arc<crate::downloads::Downloads>,
 162     clear_cookies: std::sync::Arc<std::sync::atomic::AtomicBool>,
 163     session: *mut WebKitNetworkSession,
 164     download_started: Rc<Cell<bool>>,
 165     /// A page asked something and is blocked until we answer.
 166     prompts: Rc<RefCell<Prompts>>,
 167     /// A frame has been uploaded that nothing has drawn yet.
 168     ///
 169     /// The readback is paced by this: while it is set, a finished buffer is
 170     /// left *held* instead of being copied, and the next engine frame hands it
 171     /// back unread. An animating page in a window nobody is drawing — occluded,
 172     /// on another desktop — therefore costs nothing, where before it copied
 173     /// its full window size sixty times a second into a picture no one saw.
 174     pending_draw: Cell<bool>,
 175     /// The injected account watcher, kept so the setting can take it away
 176     /// again. `None` when account autocomplete is off, which is also when no
 177     /// page carries the script at all.
 178     watcher: Option<*mut WebKitUserScript>,
 179     /// Retained only so tests can assert on rendered output; the registry
 180     /// owns the copy that actually gets drawn.
 181     /// Top-left pixel of the last frame — three bytes, not the frame.
 182     last_pixel: Option<(u8, u8, u8)>,
 183     /// Installed on every webview when force-dark is on.
 184     ucm: *mut WebKitUserContentManager,
 185     /// A pre-built hidden webview parked on about:blank, WebProcess already
 186     /// spawned. `open_tab` adopts it and pays only the navigation — measured
 187     /// at ~65ms to a live internal page against ~250ms building from scratch
 188     /// (~200ms of which is webview creation + process spawn). The price is
 189     /// one idle WebProcess held per window. Theme changes reach it anyway:
 190     /// the colour scheme is display-level and force-dark lives in the shared
 191     /// user-content-manager it was built with.
 192     spare: Option<(*mut WebKitWebView, *mut WPEView, Rc<TabState>)>,
 193 }
 194 
 195 unsafe fn cstr(s: &str) -> CString {
 196     CString::new(s).expect("no interior nul")
 197 }
 198 
 199 impl WebKitHost {
 200     /// Boot WPE and open the first tab.
 201     ///
 202     /// One host per process: the frame sink and the GType registrations are
 203     /// process-wide. That matches the app (one browser window per process)
 204     /// but is worth knowing before writing a test that builds two.
 205     pub fn new(url: Url, size_px: (u32, u32)) -> Self {
 206         unsafe {
 207             let t = types();
 208             let display = g_object_new(t.display, std::ptr::null::<c_char>()) as *mut WPEDisplay;
 209             let mut err: *mut GError = std::ptr::null_mut();
 210             assert!(
 211                 wpe_display_connect(display, &mut err) != 0,
 212                 "wpe_display_connect failed"
 213             );
 214 
 215             // Persisted profile. The data directory persists website data
 216             // (localStorage, IndexedDB, service workers) on its own, but the
 217             // cookie store stays memory-only until it is explicitly given a
 218             // file — the set_persistent_storage call below, without which
 219             // every launch starts logged out of every site even though the
 220             // rest of the profile survives. Same location and the same 0700
 221             // reasoning as the Servo backend — the jar holds live sessions.
 222             let profile = crate::pages::state_dir().join("profile");
 223             let _ = std::fs::create_dir_all(&profile);
 224             {
 225                 use std::os::unix::fs::PermissionsExt;
 226                 let _ = std::fs::set_permissions(&profile, std::fs::Permissions::from_mode(0o700));
 227             }
 228             let (data_dir, cache_dir) = (
 229                 cstr(&profile.to_string_lossy()),
 230                 cstr(&profile.join("cache").to_string_lossy()),
 231             );
 232             let session = webkit_network_session_new(data_dir.as_ptr(), cache_dir.as_ptr());
 233             let cookie_db = cstr(&profile.join("cookies.sqlite").to_string_lossy());
 234             webkit_cookie_manager_set_persistent_storage(
 235                 webkit_network_session_get_cookie_manager(session),
 236                 cookie_db.as_ptr(),
 237                 WebKitCookiePersistentStorage::WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE,
 238             );
 239 
 240             let history = std::sync::Arc::new(crate::pages::History::load());
 241             let bookmarks = std::sync::Arc::new(crate::pages::Bookmarks::load());
 242             let favorites = std::sync::Arc::new(crate::pages::Favorites::load());
 243             let downloads = std::sync::Arc::new(crate::downloads::Downloads::default());
 244             let clear_cookies =
 245                 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
 246             let protocol = Rc::new(crate::pages::CceProtocol {
 247                 history: history.clone(),
 248                 bookmarks: bookmarks.clone(),
 249                 favorites: favorites.clone(),
 250                 downloads: downloads.clone(),
 251                 clear_cookies: clear_cookies.clone(),
 252             });
 253 
 254             // The `cce:` scheme, served straight out of the app exactly as the
 255             // Servo backend serves it — same routing table, so the pages and
 256             // their mutating links behave identically on both engines.
 257             let ctx = webkit_web_context_get_default();
 258             let scheme = cstr("cce");
 259             webkit_web_context_register_uri_scheme(
 260                 ctx,
 261                 scheme.as_ptr(),
 262                 Some(on_cce_request),
 263                 Rc::into_raw(protocol.clone()) as gpointer,
 264                 None,
 265             );
 266 
 267             let download_started = Rc::new(Cell::new(false));
 268 
 269             // WebKit fetches downloads itself, and decides what *is* one by
 270             // content type — so the extension sniff `is_download_url` exists
 271             // for is simply not needed here, and neither is the argv/URL-bar
 272             // blind spot it created.
 273             let ctxs = Rc::new(DownloadCtx {
 274                 downloads: downloads.clone(),
 275                 started: download_started.clone(),
 276             });
 277             let sig = cstr("download-started");
 278             g_signal_connect_data(
 279                 session as *mut _,
 280                 sig.as_ptr(),
 281                 Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
 282                     on_download_started
 283                         as unsafe extern "C" fn(*mut GObject, *mut WebKitDownload, gpointer),
 284                 )),
 285                 Rc::into_raw(ctxs) as gpointer,
 286                 None,
 287                 0,
 288             );
 289 
 290             let prompts = Rc::new(RefCell::new(Prompts::default()));
 291             let pending = Rc::new(std::cell::RefCell::new(Pending::default()));
 292             let sink = pending.clone();
 293             FRAME_SINK = Some(Box::new(move |view: *mut WPEView, buffer: *mut WPEBuffer| {
 294                 let mut slot = sink.borrow_mut();
 295                 // Replace, never accumulate: the newest frame wins. The one it
 296                 // supersedes goes back to the engine **without being read** —
 297                 // several frames can be dispatched inside a single pump's
 298                 // drain, and only the last of them will ever be shown, so the
 299                 // rest are not worth 35 MB of copying each.
 300                 if let Some((old_view, old_buffer)) = slot.held.replace((view, buffer)) {
 301                     wpe_view_buffer_released(old_view, old_buffer);
 302                 }
 303                 if frame_debug() {
 304                     slot.counts.produced += 1;
 305                 }
 306                 true
 307             }));
 308 
 309             let toplevel = wpe_display_create_toplevel(display, 1);
 310             // Scale is 1 until the first `resize` from a real window; the
 311             // constructor's size is already logical.
 312             wpe_toplevel_resized(toplevel, size_px.0 as i32, size_px.1 as i32);
 313 
 314             let mut host = Self {
 315                 display,
 316                 toplevel,
 317                 tabs: Vec::new(),
 318                 active: usize::MAX, // sentinel: force activate() to do the work
 319                 size_px,
 320                 scale: 1.0,
 321                 pending,
 322                 poll: GlibPoll::new()
 323                     .map_err(|e| log::warn!("no GLib epoll bridge ({e}); pump will poll"))
 324                     .ok(),
 325                 history: history.clone(),
 326                 bookmarks: bookmarks.clone(),
 327                 favorites,
 328                 history_enabled: true,
 329                 force_dark: false,
 330                 protocol,
 331                 downloads,
 332                 clear_cookies,
 333                 session,
 334                 download_started,
 335                 pending_draw: Cell::new(false),
 336                 prompts,
 337                 last_pixel: None,
 338                 ucm: webkit_user_content_manager_new(),
 339                 watcher: None,
 340                 spare: None,
 341             };
 342             // The account watcher's channel, in its own script world. Both
 343             // halves are registered here, once, on the shared content
 344             // manager every tab is built against.
 345             host.register_account_channel();
 346             host.open_tab(url);
 347             host
 348         }
 349     }
 350 
 351     /// Listen for the account watcher's messages, in its private world.
 352     ///
 353     /// The world is the security boundary: page script cannot post on a
 354     /// channel registered for another world, so a message arriving here came
 355     /// from the injected watcher and not from the page pretending to be one.
 356     fn register_account_channel(&self) {
 357         use super::formwatch;
 358         unsafe {
 359             let name = cstr(formwatch::CHANNEL);
 360             let world = cstr(formwatch::WORLD);
 361             if webkit_user_content_manager_register_script_message_handler(
 362                 self.ucm,
 363                 name.as_ptr(),
 364                 world.as_ptr(),
 365             ) == 0
 366             {
 367                 log::warn!("could not register the account message channel");
 368                 return;
 369             }
 370             let signal = cstr(&format!("script-message-received::{}", formwatch::CHANNEL));
 371             g_signal_connect_data(
 372                 self.ucm as *mut _,
 373                 signal.as_ptr(),
 374                 Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
 375                     on_account_message as *const () as usize,
 376                 )),
 377                 Rc::into_raw(self.prompts.clone()) as gpointer,
 378                 Some(drop_prompts_ref),
 379                 0,
 380             );
 381         }
 382     }
 383 
 384     /// Install or remove the login-field watcher — the whole page-side
 385     /// footprint of the feature, so a browser with accounts turned off
 386     /// injects nothing at all.
 387     pub fn set_accounts_enabled(&mut self, on: bool) {
 388         use super::formwatch;
 389         unsafe {
 390             match (on, self.watcher.take()) {
 391                 (true, None) => {
 392                     let source = cstr(formwatch::WATCH_JS);
 393                     let world = cstr(formwatch::WORLD);
 394                     // Top frame only, and at document start so the listeners
 395                     // are in place before a login page's own script runs.
 396                     let script = webkit_user_script_new_for_world(
 397                         source.as_ptr(),
 398                         WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
 399                         WebKitUserScriptInjectionTime::WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
 400                         world.as_ptr(),
 401                         std::ptr::null(),
 402                         std::ptr::null(),
 403                     );
 404                     webkit_user_content_manager_add_script(self.ucm, script);
 405                     self.watcher = Some(script);
 406                 }
 407                 (false, Some(script)) => {
 408                     webkit_user_content_manager_remove_script(self.ucm, script);
 409                     webkit_user_script_unref(script);
 410                 }
 411                 // Already in the asked-for state; `take` above is why the
 412                 // enabled case has to put its handle back.
 413                 (true, Some(script)) => self.watcher = Some(script),
 414                 (false, None) => {}
 415             }
 416         }
 417     }
 418 
 419     /// Nudge the watcher into re-reporting the focused login field, for when
 420     /// the chrome has something to offer that it did not have a moment ago.
 421     pub fn request_form_state(&self) {
 422         if self.watcher.is_none() {
 423             return;
 424         }
 425         unsafe {
 426             let source = cstr(super::formwatch::RESCAN_JS);
 427             let world = cstr(super::formwatch::WORLD);
 428             webkit_web_view_evaluate_javascript(
 429                 self.active_tab().webview,
 430                 source.as_ptr(),
 431                 -1,
 432                 world.as_ptr(),
 433                 std::ptr::null(),
 434                 std::ptr::null_mut(),
 435                 None,
 436                 std::ptr::null_mut(),
 437             );
 438         }
 439     }
 440 
 441     /// The next login-field event the watcher reported.
 442     pub fn take_form_event(&self) -> Option<super::formwatch::FormEvent> {
 443         self.prompts.borrow_mut().form_events.pop_front()
 444     }
 445 
 446     /// Drop anything the watcher reported for a page that is going away, so a
 447     /// stale focus cannot open a list over the next one.
 448     pub fn clear_form_events(&self) {
 449         self.prompts.borrow_mut().form_events.clear();
 450     }
 451 
 452     /// Put a picked account into the page's login fields.
 453     ///
 454     /// Runs in the watcher's world, where the elements it recorded live and
 455     /// where the page cannot have replaced the setter being used. The script
 456     /// carries the credential, so it is built here and dropped immediately;
 457     /// it is never logged, and `source_uri` is left null so it cannot show up
 458     /// named in a devtools listing either.
 459     pub fn fill_credentials(&self, username: &str, password: &str) {
 460         use super::formwatch;
 461         let script = formwatch::fill_js(username, password);
 462         unsafe {
 463             let source = cstr(&script);
 464             let world = cstr(formwatch::WORLD);
 465             webkit_web_view_evaluate_javascript(
 466                 self.active_tab().webview,
 467                 source.as_ptr(),
 468                 -1,
 469                 world.as_ptr(),
 470                 std::ptr::null(),
 471                 std::ptr::null_mut(),
 472                 None,
 473                 std::ptr::null_mut(),
 474             );
 475         }
 476     }
 477 
 478     fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
 479         unsafe {
 480             let (p_display, p_ucm, p_session) = (
 481                 cstr("display"),
 482                 cstr("user-content-manager"),
 483                 cstr("network-session"),
 484             );
 485             let wv = g_object_new(
 486                 webkit_web_view_get_type(),
 487                 p_display.as_ptr(),
 488                 self.display,
 489                 p_ucm.as_ptr(),
 490                 self.ucm,
 491                 p_session.as_ptr(),
 492                 self.session,
 493                 std::ptr::null::<c_char>(),
 494             ) as *mut WebKitWebView;
 495             let view = webkit_web_view_get_wpe_view(wv);
 496             wpe_view_set_toplevel(view, self.toplevel);
 497             // Signals, not polling: a background tab has to be able to report
 498             // its title without anyone asking the active webview.
 499             for sig in ["notify::title", "notify::uri", "notify::is-loading"] {
 500                 connect_notify(wv, sig, state);
 501             }
 502             // A page's alert/confirm/prompt, and HTTP auth challenges. Both
 503             // are held open and answered later, so the chrome can draw a real
 504             // dialog rather than the handler having to decide inline.
 505             connect_raw(
 506                 wv,
 507                 "script-dialog",
 508                 on_script_dialog as *const () as usize,
 509                 &self.prompts,
 510             );
 511             connect_raw(
 512                 wv,
 513                 "authenticate",
 514                 on_authenticate as *const () as usize,
 515                 &self.prompts,
 516             );
 517             // Right-click reaches the page as button 3; if the page does not
 518             // preventDefault, WebKit asks for a menu here. Returning TRUE
 519             // claims presentation, so the chrome draws it.
 520             connect_raw(
 521                 wv,
 522                 "context-menu",
 523                 on_context_menu as *const () as usize,
 524                 &self.prompts,
 525             );
 526             let (lw, lh) = self.logical_size();
 527             wpe_view_resized(view, lw, lh);
 528             wpe_view_set_visible(view, 1);
 529             wpe_view_map(view);
 530             let curl = cstr(url.as_str());
 531             webkit_web_view_load_uri(wv, curl.as_ptr());
 532             (wv, view)
 533         }
 534     }
 535 
 536     /// Build the hidden spare webview so its WebProcess is up before the
 537     /// next `open_tab` needs it.
 538     fn prewarm_spare(&mut self) {
 539         if self.spare.is_some() {
 540             return;
 541         }
 542         let state = Rc::new(TabState::default());
 543         let url = Url::parse("about:blank").expect("about:blank");
 544         let (wv, view) = self.build_webview(&url, &state);
 545         unsafe {
 546             wpe_view_unmap(view);
 547             wpe_view_set_visible(view, 0);
 548         }
 549         self.spare = Some((wv, view, state));
 550     }
 551 
 552     pub fn open_tab(&mut self, url: Url) {
 553         let (webview, view, state) = match self.spare.take() {
 554             // Adopt the prewarmed webview; only the navigation is paid.
 555             Some((wv, view, state)) => {
 556                 unsafe {
 557                     let curl = cstr(url.as_str());
 558                     webkit_web_view_load_uri(wv, curl.as_ptr());
 559                 }
 560                 (wv, view, state)
 561             }
 562             None => {
 563                 let state = Rc::new(TabState::default());
 564                 let (wv, view) = self.build_webview(&url, &state);
 565                 (wv, view, state)
 566             }
 567         };
 568         state.loading.set(true);
 569         *state.url.borrow_mut() = Some(url.clone());
 570         self.tabs.push(Tab {
 571             webview,
 572             view,
 573             state,
 574             title: None,
 575             url: Some(url),
 576             loading: true,
 577             image: None,
 578         });
 579         self.activate(self.tabs.len() - 1);
 580         // Replace the spare right away, but after the load started, so the
 581         // page fetch runs while this builds — measured, it does not show up
 582         // in the click-to-tab time.
 583         self.prewarm_spare();
 584     }
 585 
 586     /// Close a tab. Returns false when that was the last one (the app should
 587     /// exit; the tab is gone either way). Mirrors `ServoHost::close_tab`,
 588     /// including how the next active index is chosen.
 589     pub fn close_tab(&mut self, index: usize) -> bool {
 590         if index >= self.tabs.len() {
 591             return true;
 592         }
 593         let was_active = index == self.active;
 594         let old_active = self.active;
 595         // Anything still held belongs to a view that may be the one about to
 596         // be destroyed; hand it back while it is still safe to. Losing that
 597         // frame costs a repaint, which the tab change causes anyway.
 598         self.release_held();
 599         // Dropping the Tab unrefs the webview and frees its registry image.
 600         drop(self.tabs.remove(index));
 601         if self.tabs.is_empty() {
 602             return false;
 603         }
 604         let next = if was_active {
 605             index.min(self.tabs.len() - 1)
 606         } else if old_active > index {
 607             old_active - 1
 608         } else {
 609             old_active
 610         };
 611         self.active = usize::MAX; // force activate() to do the work
 612         self.activate(next);
 613         true
 614     }
 615 
 616     /// Give back an unread buffer, if one is being held.
 617     fn release_held(&self) {
 618         if let Some((view, buffer)) = self.pending.borrow_mut().held.take() {
 619             unsafe { wpe_view_buffer_released(view, buffer) };
 620         }
 621     }
 622 
 623     /// Make tab `index` visible and focused. Mirrors `ServoHost::activate`,
 624     /// including the `usize::MAX` sentinel so the first call is not a no-op.
 625     pub fn activate(&mut self, index: usize) {
 626         if index >= self.tabs.len() || index == self.active {
 627             return;
 628         }
 629         unsafe {
 630             if let Some(old) = self.tabs.get(self.active) {
 631                 wpe_view_unmap(old.view);
 632                 wpe_view_set_visible(old.view, 0);
 633             }
 634             self.active = index;
 635             let tab = &self.tabs[index];
 636             wpe_view_set_toplevel(tab.view, self.toplevel);
 637             wpe_view_set_visible(tab.view, 1);
 638             wpe_view_map(tab.view);
 639             let (lw, lh) = self.logical_size();
 640             wpe_view_resized(tab.view, lw, lh);
 641         }
 642     }
 643 
 644     pub fn tab_count(&self) -> usize {
 645         self.tabs.len()
 646     }
 647     pub fn active_index(&self) -> usize {
 648         self.active
 649     }
 650     pub fn tab(&self, index: usize) -> Option<&Tab> {
 651         self.tabs.get(index)
 652     }
 653     fn active_tab(&self) -> &Tab {
 654         &self.tabs[self.active]
 655     }
 656 
 657     /// The epoll fd carrying GLib's pollfd set, for `register_sources`.
 658     /// `None` if the bridge could not be created, in which case the app must
 659     /// fall back to calling [`Self::pump`] on a timer.
 660     pub fn poll_fd(&self) -> Option<std::os::fd::BorrowedFd<'_>> {
 661         self.poll.as_ref().map(|p| p.fd())
 662     }
 663 
 664     /// An owned duplicate of [`Self::poll_fd`], for handing to calloop.
 665     ///
 666     /// calloop wants to own what it polls, and the borrow above is tied to
 667     /// `&self`. A dup refers to the same epoll instance, so registrations
 668     /// made through the original are still what this observes.
 669     pub fn poll_fd_owned(&self) -> Option<std::os::fd::OwnedFd> {
 670         let fd = self.poll.as_ref()?.fd();
 671         rustix::io::dup(fd).ok()
 672     }
 673 
 674     /// How long calloop may sleep before pumping anyway, per GLib.
 675     pub fn poll_timeout(&self) -> Option<std::time::Duration> {
 676         self.poll
 677             .as_ref()
 678             .and_then(|p| p.timeout)
 679             .map(|ms| std::time::Duration::from_millis(ms as u64))
 680     }
 681 
 682     /// Drain GLib's pending work, then upload any frame it produced.
 683     /// Returns (new frame, any state change) like `ServoHost::pump`.
 684     pub fn pump(&mut self) -> (bool, bool) {
 685         // Clear the inner epoll first: calloop is level-triggered on that fd,
 686         // so leaving it readable across a pump that does not consume the
 687         // underlying socket would spin the loop.
 688         if let Some(p) = &self.poll {
 689             p.drain();
 690         }
 691         // `cce://cookies/clear` runs on WebKit's fetch path and cannot reach
 692         // the session from there, so it sets the flag and this acts on it —
 693         // the same relay `ServoHost::pump` uses. Timespan 0 clears them all.
 694         if self
 695             .clear_cookies
 696             .swap(false, std::sync::atomic::Ordering::SeqCst)
 697         {
 698             unsafe {
 699                 webkit_website_data_manager_clear(
 700                     webkit_network_session_get_website_data_manager(self.session),
 701                     WebKitWebsiteDataTypes::WEBKIT_WEBSITE_DATA_COOKIES,
 702                     0,
 703                     std::ptr::null_mut(),
 704                     None,
 705                     std::ptr::null_mut(),
 706                 );
 707             }
 708         }
 709         unsafe {
 710             while g_main_context_iteration(std::ptr::null_mut(), 0) != 0 {}
 711         }
 712         // WebKit opens and drops sockets as it loads, so the set that matters
 713         // is the one *after* dispatch, not before.
 714         if let Some(p) = &mut self.poll {
 715             p.sync();
 716         }
 717         // Nothing has drawn the last frame yet, so reading another would be
 718         // copying over a picture that was never shown. Leave the buffer held:
 719         // the engine's next frame supersedes it and hands it back unread.
 720         if self.pending_draw.get() {
 721             return (false, self.sync_page_state());
 722         }
 723         // One readback per pump, of the newest buffer only: everything the
 724         // engine rendered in between was handed back unread.
 725         let held = self.pending.borrow_mut().held.take();
 726         let dirty = self.sync_page_state();
 727         let Some((view, buffer)) = held else {
 728             return (false, dirty);
 729         };
 730         let frame = unsafe {
 731             let f = read_shm(buffer);
 732             // The pixels are ours now; the memory can go back.
 733             wpe_view_buffer_released(view, buffer);
 734             f
 735         };
 736         if frame_debug() {
 737             let mut p = self.pending.borrow_mut();
 738             p.counts.read += 1;
 739             let now = std::time::Instant::now();
 740             let due = p.reported.is_none_or(|t| now.duration_since(t).as_secs_f32() >= 1.0);
 741             if due {
 742                 p.reported = Some(now);
 743                 let (produced, read) = (p.counts.produced, p.counts.read);
 744                 p.counts = FrameCounts::default();
 745                 log::info!(
 746                     "frames: engine produced {produced}, read back {read} \
 747                      ({} handed back unread)",
 748                     produced.saturating_sub(read)
 749                 );
 750             }
 751         }
 752         let Some((px, w, h)) = frame else {
 753             return (false, dirty);
 754         };
 755         // The one pixel anything actually reads back (see `sample_pixel`),
 756         // kept instead of a copy of the whole frame. Cloning 35 MB per frame
 757         // to serve a three-byte question cost 7 ms of every frame.
 758         self.last_pixel = (px.len() >= 4).then(|| (px[2], px[1], px[0]));
 759         let tab = &mut self.tabs[self.active];
 760         match tab.image {
 761             // Same tab, same size: replace the contents of the image that is
 762             // already there. No allocation, no descriptor, and above all no
 763             // image freed — freeing one waits for the whole device to go idle,
 764             // which on this path meant once per frame.
 765             Some((id, iw, ih)) if (iw, ih) == (w, h) => {
 766                 cce_ui::vk::update_pixels(id, px, w, h, cce_ui::vk::PixelFormat::Bgra);
 767             }
 768             _ => {
 769                 let id = cce_ui::vk::upload_pixels(px, w, h, cce_ui::vk::PixelFormat::Bgra);
 770                 if let Some((old, ..)) = tab.image.replace((id, w, h)) {
 771                     cce_ui::vk::free_image(old);
 772                 }
 773                 tab.image = Some((id, w, h));
 774             }
 775         }
 776         self.pending_draw.set(true);
 777         (true, true)
 778     }
 779 
 780     /// Re-paint the page into a renderer that has just replaced the one the
 781     /// tab images were uploaded to.
 782     ///
 783     /// An image id belongs to a **renderer**, not to the process: `cce-ui`'s
 784     /// `window_runner` repairs a lost Wayland transport by opening a new
 785     /// session around the same `Application`, which rebuilds the renderer and
 786     /// with it the image table. A draw for an unknown id is skipped rather
 787     /// than reported, so the chrome came back over an empty page.
 788     ///
 789     /// Two halves. Dropping the ids is the easy one. The hard one is that
 790     /// nothing would otherwise provoke a new frame: a page that has finished
 791     /// loading renders once and then only on damage, so `pump` would find no
 792     /// buffer held and the window would sit blank until the user scrolled or
 793     /// navigated. Remapping the active view is the nudge — it is what
 794     /// `activate` already relies on to get a frame out of a tab being
 795     /// switched to.
 796     ///
 797     /// A buffer still held from the old session is deliberately kept: its
 798     /// pixels are fine, and the next `pump` uploads them under a fresh id.
 799     pub fn renderer_replaced(&mut self) {
 800         for tab in &mut self.tabs {
 801             if let Some((id, ..)) = tab.image.take() {
 802                 // A free for an id the new renderer never had is a no-op, and
 803                 // ids are process-unique, so this cannot reach a live image.
 804                 cce_ui::vk::free_image(id);
 805             }
 806         }
 807         unsafe {
 808             let view = self.active_tab().view;
 809             wpe_view_unmap(view);
 810             wpe_view_set_visible(view, 1);
 811             wpe_view_map(view);
 812             let (lw, lh) = self.logical_size();
 813             wpe_view_resized(view, lw, lh);
 814         }
 815     }
 816 
 817     /// The chrome drew: whatever was uploaded is on screen, so the next
 818     /// engine frame is worth reading. Called from `display_list`.
 819     pub fn frame_drawn(&self) {
 820         self.pending_draw.set(false);
 821     }
 822 
 823     /// Fold each tab's signal-written state into the fields the chrome reads.
 824     ///
 825     /// Every tab, not just the active one — that is the whole point of moving
 826     /// off polling. The tab strip shows a title per tab, so a background tab
 827     /// finishing a load has to be visible without switching to it.
 828     fn sync_page_state(&mut self) -> bool {
 829         let mut changed = false;
 830         for tab in &mut self.tabs {
 831             if !tab.state.dirty.replace(false) {
 832                 continue;
 833             }
 834             tab.title = tab.state.title.borrow().clone();
 835             if let Some(u) = tab.state.url.borrow().clone() {
 836                 tab.url = Some(u);
 837             }
 838             tab.loading = tab.state.loading.get();
 839             changed = true;
 840         }
 841         changed
 842     }
 843 
 844     /// Top-left pixel of the last frame, for tests that need to assert on
 845     /// what was actually rendered rather than on what was configured.
 846     pub fn sample_pixel(&self) -> Option<(u8, u8, u8)> {
 847         self.last_pixel
 848     }
 849 
 850     pub fn image(&self) -> Option<(u32, u32, u32)> {
 851         self.active_tab().image
 852     }
 853     pub fn title(&self) -> Option<String> {
 854         self.active_tab().title.clone()
 855     }
 856     pub fn url(&self) -> Option<Url> {
 857         self.active_tab().url.clone()
 858     }
 859     pub fn loading(&self) -> bool {
 860         self.active_tab().loading
 861     }
 862 
 863     pub fn load(&self, url: Url) {
 864         unsafe {
 865             let c = cstr(url.as_str());
 866             webkit_web_view_load_uri(self.active_tab().webview, c.as_ptr());
 867         }
 868     }
 869     pub fn reload(&self) {
 870         unsafe { webkit_web_view_reload(self.active_tab().webview) }
 871     }
 872     pub fn back(&self) {
 873         unsafe { webkit_web_view_go_back(self.active_tab().webview) }
 874     }
 875     pub fn forward(&self) {
 876         unsafe { webkit_web_view_go_forward(self.active_tab().webview) }
 877     }
 878     pub fn can_go_back(&self) -> bool {
 879         unsafe { webkit_web_view_can_go_back(self.active_tab().webview) != 0 }
 880     }
 881     pub fn can_go_forward(&self) -> bool {
 882         unsafe { webkit_web_view_can_go_forward(self.active_tab().webview) != 0 }
 883     }
 884 
 885     // ---- settings and app-side state ----
 886     //
 887     // These exist so `WebKitHost` and `ServoHost` present the same surface;
 888     // bookmarks and history are app state either way, so they are identical.
 889 
 890     pub fn set_history_enabled(&mut self, on: bool) {
 891         self.history_enabled = on;
 892     }
 893 
 894     /// Install or remove the inverting user stylesheet.
 895     ///
 896     /// Simpler than the Servo path, which needed *two* timed reloads to let
 897     /// a user-content change and a scheme flip settle. WebKit applies user
 898     /// content to live pages, so a reload is enough — and only to re-run
 899     /// pages that already computed their colours.
 900     pub fn set_force_dark(&mut self, on: bool) {
 901         if on == self.force_dark {
 902             return;
 903         }
 904         self.force_dark = on;
 905         unsafe {
 906             if on {
 907                 let css = cstr(FORCE_DARK_CSS);
 908                 let sheet = webkit_user_style_sheet_new(
 909                     css.as_ptr(),
 910                     WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
 911                     WebKitUserStyleLevel::WEBKIT_USER_STYLE_LEVEL_USER,
 912                     std::ptr::null(),
 913                     std::ptr::null(),
 914                 );
 915                 webkit_user_content_manager_add_style_sheet(self.ucm, sheet);
 916                 webkit_user_style_sheet_unref(sheet);
 917             } else {
 918                 webkit_user_content_manager_remove_all_style_sheets(self.ucm);
 919             }
 920             for tab in &self.tabs {
 921                 webkit_web_view_reload(tab.webview);
 922             }
 923         }
 924     }
 925 
 926     /// What pages see for `prefers-color-scheme`, via WPE's own setting.
 927     pub fn set_color_scheme_dark(&self, dark: bool) {
 928         unsafe {
 929             let settings = wpe_display_get_settings(self.display);
 930             let key = cstr("/wpe-platform/dark-mode");
 931             let mut err: *mut GError = std::ptr::null_mut();
 932             wpe_settings_set_boolean(
 933                 settings,
 934                 key.as_ptr(),
 935                 dark as gboolean,
 936                 WPESettingsSource::WPE_SETTINGS_SOURCE_APPLICATION,
 937                 &mut err,
 938             );
 939         }
 940     }
 941 
 942     /// A navigation became a download since the last check.
 943     pub fn take_download_started(&self) -> bool {
 944         self.download_started.replace(false)
 945     }
 946 
 947     pub fn active_bookmarked(&self) -> bool {
 948         self.active_tab()
 949             .url
 950             .as_ref()
 951             .is_some_and(|u| self.bookmarks.contains(u.as_str()))
 952     }
 953 
 954     pub fn toggle_bookmark(&self) {
 955         let tab = self.active_tab();
 956         if let Some(url) = &tab.url {
 957             self.bookmarks
 958                 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
 959         }
 960     }
 961 
 962     /// The bookmarks store, shared with the `cce://bookmarks` page; the
 963     /// chrome's bookmarks menu lists and edits it directly.
 964     pub fn bookmarks(&self) -> std::sync::Arc<crate::pages::Bookmarks> {
 965         self.bookmarks.clone()
 966     }
 967 
 968     /// The favorites store, shared with the `cce://favorites` page; the
 969     /// chrome reads the strip from it.
 970     pub fn favorites(&self) -> std::sync::Arc<crate::pages::Favorites> {
 971         self.favorites.clone()
 972     }
 973 
 974     pub fn active_favorited(&self) -> bool {
 975         self.active_tab()
 976             .url
 977             .as_ref()
 978             .is_some_and(|u| self.favorites.contains(u.as_str()))
 979     }
 980 
 981     pub fn toggle_favorite(&self) {
 982         let tab = self.active_tab();
 983         if let Some(url) = &tab.url {
 984             self.favorites
 985                 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
 986         }
 987     }
 988 
 989     /// Clipboard on the page. WebKit takes these as named editing commands,
 990     /// so unlike the Servo backend there is no separate clipboard delegate to
 991     /// implement — it goes through the platform clipboard itself.
 992     /// Push the system selection into WPE. Separated so it can be done
 993     /// ahead of a paste rather than in the same breath — the web process is
 994     /// a different process, and the content has to reach it.
 995     pub fn sync_clipboard(&self) {
 996         unsafe { super::subclass::sync_system_clipboard(self.display) }
 997     }
 998 
 999     pub fn editing_action_cmd(&self, command: crate::EditingCommand) {
1000         unsafe {
1001             // WebKit will not read a clipboard it thinks is empty, so the
1002             // system selection has to be pushed in before Paste runs.
1003             if matches!(command, crate::EditingCommand::Paste) {
1004                 super::subclass::sync_system_clipboard(self.display);
1005             }
1006             let c = cstr(match command {
1007                 crate::EditingCommand::Copy => "Copy",
1008                 crate::EditingCommand::Cut => "Cut",
1009                 crate::EditingCommand::Paste => "Paste",
1010             });
1011             webkit_web_view_execute_editing_command(self.active_tab().webview, c.as_ptr());
1012         }
1013     }
1014 
1015     // ---- pending prompts ----
1016 
1017     /// The dialog a page is currently blocked on, if any. Cloned rather than
1018     /// taken: the chrome redraws from this every frame, and the page stays
1019     /// blocked until [`Self::respond_dialog`].
1020     pub fn pending_dialog(&self) -> Option<PendingDialog> {
1021         self.prompts.borrow().dialog.as_ref().map(|(_, d)| d.clone())
1022     }
1023 
1024     /// One-shot: the context menu the page just requested, if any. Taken
1025     /// rather than cloned — the chrome opens it once, at the pointer.
1026     pub fn take_context_menu(&self) -> Option<ContextMenuInfo> {
1027         self.prompts.borrow_mut().context_menu.take()
1028     }
1029 
1030     /// Fetch `uri` through WebKit's download pipeline — same signals, same
1031     /// store, same `cce://downloads` page as a navigated download. This is
1032     /// what "Download Link/Image" in the context menu dispatches to.
1033     pub fn download_uri(&self, uri: &str) {
1034         unsafe {
1035             let c = cstr(uri);
1036             webkit_web_view_download_uri(self.active_tab().webview, c.as_ptr());
1037         }
1038     }
1039 
1040     pub fn pending_auth(&self) -> Option<PendingAuth> {
1041         self.prompts.borrow().auth.as_ref().map(|(_, a)| a.clone())
1042     }
1043 
1044     /// Answer the page. `text` carries a `prompt`'s reply; it is ignored for
1045     /// alert and confirm.
1046     pub fn respond_dialog(&self, ok: bool, text: Option<&str>) {
1047         let Some((dialog, pending)) = self.prompts.borrow_mut().dialog.take() else {
1048             return;
1049         };
1050         unsafe {
1051             if pending.prompt_default.is_some() {
1052                 // A cancelled prompt must return null, not "" — a page
1053                 // distinguishes the two.
1054                 if ok {
1055                     let t = cstr(text.unwrap_or(""));
1056                     webkit_script_dialog_prompt_set_text(dialog, t.as_ptr());
1057                 } else {
1058                     webkit_script_dialog_prompt_set_text(dialog, std::ptr::null());
1059                 }
1060             } else if pending.has_cancel {
1061                 webkit_script_dialog_confirm_set_confirmed(dialog, ok as gboolean);
1062             }
1063             webkit_script_dialog_close(dialog);
1064             webkit_script_dialog_unref(dialog);
1065         }
1066     }
1067 
1068     /// Answer an auth challenge, or cancel it. Credentials are used for this
1069     /// session only — `WEBKIT_CREDENTIAL_PERSISTENCE_FOR_SESSION` — rather
1070     /// than written to the profile, which would need a deliberate decision
1071     /// about storing passwords on disk.
1072     pub fn respond_auth(&self, credentials: Option<(&str, &str)>) {
1073         let Some((request, _)) = self.prompts.borrow_mut().auth.take() else {
1074             return;
1075         };
1076         unsafe {
1077             match credentials {
1078                 Some((user, password)) => {
1079                     let (u, p) = (cstr(user), cstr(password));
1080                     let cred = webkit_credential_new(
1081                         u.as_ptr(),
1082                         p.as_ptr(),
1083                         WebKitCredentialPersistence::WEBKIT_CREDENTIAL_PERSISTENCE_FOR_SESSION,
1084                     );
1085                     webkit_authentication_request_authenticate(request, cred);
1086                     webkit_credential_free(cred);
1087                 }
1088                 None => webkit_authentication_request_cancel(request),
1089             }
1090             g_object_unref(request as *mut _);
1091         }
1092     }
1093 
1094     // ---- input ----
1095     //
1096     // Coordinates are device pixels relative to the view origin, matching
1097     // `ServoHost`'s convention so `main.rs` scales them the same way. Events
1098     // are refcounted; `wpe_view_event` takes its own reference, so each one is
1099     // unreffed here after delivery.
1100 
1101     pub fn mouse_move(&self, x_px: f32, y_px: f32) {
1102         unsafe {
1103             let view = self.active_tab().view;
1104             let (x, y) = self.to_logical(x_px, y_px);
1105             let e = wpe_event_pointer_move_new(
1106                 WPEEventType::WPE_EVENT_POINTER_MOVE,
1107                 view,
1108                 WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
1109                 input::now_ms(),
1110                 0,
1111                 x,
1112                 y,
1113                 0.0,
1114                 0.0,
1115             );
1116             self.send(view, e);
1117         }
1118     }
1119 
1120     pub fn mouse_button_ui(&self, button: MouseButton, pressed: bool, x_px: f32, y_px: f32) {
1121         let Some(n) = input::button_number(button) else {
1122             return;
1123         };
1124         unsafe {
1125             let view = self.active_tab().view;
1126             let time = input::now_ms();
1127             // WPE tracks double/triple clicks for us; a frozen clock here
1128             // would make every click read as a repeat.
1129             let (x, y) = self.to_logical(x_px, y_px);
1130             let press_count = if pressed {
1131                 wpe_view_compute_press_count(view, x, y, n, time)
1132             } else {
1133                 0
1134             };
1135             let e = wpe_event_pointer_button_new(
1136                 if pressed {
1137                     WPEEventType::WPE_EVENT_POINTER_DOWN
1138                 } else {
1139                     WPEEventType::WPE_EVENT_POINTER_UP
1140                 },
1141                 view,
1142                 WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
1143                 time,
1144                 0,
1145                 n,
1146                 x,
1147                 y,
1148                 press_count,
1149             );
1150             self.send(view, e);
1151         }
1152     }
1153 
1154     /// Wheel deltas in device pixels, in cce-ui's winit convention (positive
1155     /// = scroll up), passed through **unchanged**.
1156     ///
1157     /// Measured, not assumed: WPE already inverts on the way to the DOM, so a
1158     /// negation here double-inverts and the page scrolls backwards. An
1159     /// earlier cut negated these and `examples/wpe_input` caught it — the
1160     /// page reported `deltaY` of the wrong sign.
1161     pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
1162         // cce-ui publishes the gesture phase of the wheel event being
1163         // dispatched: a trackpad's finger lift arrives as a zero delta in
1164         // FingerEnd, which is WebKit's scroll-stop — the signal its own
1165         // kinetic scrolling keys off. Finger phases report the touchpad
1166         // source so the engine treats the deltas as a gesture, not clicks.
1167         let phase = cce_ui::widget::scroll_motion::current_scroll_phase();
1168         let (source, is_stop) = match phase {
1169             cce_ui::widget::ScrollPhase::Wheel => (WPEInputSource::WPE_INPUT_SOURCE_MOUSE, 0),
1170             cce_ui::widget::ScrollPhase::Finger => (WPEInputSource::WPE_INPUT_SOURCE_TOUCHPAD, 0),
1171             cce_ui::widget::ScrollPhase::FingerEnd => (WPEInputSource::WPE_INPUT_SOURCE_TOUCHPAD, 1),
1172         };
1173         unsafe {
1174             let view = self.active_tab().view;
1175             let (x, y) = self.to_logical(x_px, y_px);
1176             let e = wpe_event_scroll_new(
1177                 view,
1178                 source,
1179                 input::now_ms(),
1180                 0,
1181                 dx_px / self.scale as f64,
1182                 dy_px / self.scale as f64,
1183                 1, // precise deltas: these are pixels, not notches
1184                 is_stop,
1185                 x,
1186                 y,
1187             );
1188             self.send(view, e);
1189         }
1190     }
1191 
1192     /// Takes cce-ui's `KeyEvent` directly — the keysym mapping lives in
1193     /// `input`, so the chrome never learns engine vocabulary.
1194     pub fn key_ui(&self, event: &KeyEvent) {
1195         let Some(keyval) = input::keyval(&event.logical_key) else {
1196             return;
1197         };
1198         let pressed = input::is_pressed(event);
1199         unsafe {
1200             let view = self.active_tab().view;
1201             let e = wpe_event_keyboard_new(
1202                 if pressed {
1203                     WPEEventType::WPE_EVENT_KEYBOARD_KEY_DOWN
1204                 } else {
1205                     WPEEventType::WPE_EVENT_KEYBOARD_KEY_UP
1206                 },
1207                 view,
1208                 WPEInputSource::WPE_INPUT_SOURCE_KEYBOARD,
1209                 input::now_ms(),
1210                 input::modifiers(event.ctrl, event.shift, event.alt),
1211                 0, // hardware keycode: unknown to us, and WebKit works off keyval
1212                 keyval,
1213             );
1214             self.send(view, e);
1215         }
1216     }
1217 
1218     /// Page focus. Without this the page has no focused frame and keyboard
1219     /// input is dropped, which looks exactly like a broken key mapping.
1220     pub fn focus(&self, focused: bool) {
1221         unsafe {
1222             let view = self.active_tab().view;
1223             if focused {
1224                 wpe_view_focus_in(view)
1225             } else {
1226                 wpe_view_focus_out(view)
1227             }
1228         }
1229     }
1230 
1231     unsafe fn send(&self, view: *mut WPEView, event: *mut WPEEvent) {
1232         if event.is_null() {
1233             return;
1234         }
1235         wpe_view_event(view, event);
1236         wpe_event_unref(event);
1237     }
1238 
1239     /// Resize, in **physical** pixels — `ServoHost`'s convention, so
1240     /// `main.rs` passes `content_px()` to either backend unchanged.
1241     ///
1242     /// WPE wants the opposite split: a **logical** size plus a scale, and it
1243     /// produces a buffer of `size * scale`. Handing it physical pixels while
1244     /// leaving the scale at 1 makes it lay out 2400x1600 *CSS* pixels on a 2x
1245     /// display — the viewport reads as twice as wide as it is and the whole
1246     /// page renders at half size. That is the bug this converts away.
1247     pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
1248         self.size_px = (width_px.max(1), height_px.max(1));
1249         self.scale = scale.max(0.01);
1250         let (lw, lh) = self.logical_size();
1251         unsafe {
1252             wpe_toplevel_scale_changed(self.toplevel, self.scale as f64);
1253             wpe_toplevel_resized(self.toplevel, lw, lh);
1254             let view = self.active_tab().view;
1255             wpe_view_resized(view, lw, lh);
1256         }
1257     }
1258 
1259     /// The view size WPE works in: physical divided back out by the scale.
1260     fn logical_size(&self) -> (i32, i32) {
1261         (
1262             ((self.size_px.0 as f32 / self.scale).round() as i32).max(1),
1263             ((self.size_px.1 as f32 / self.scale).round() as i32).max(1),
1264         )
1265     }
1266 
1267     /// Physical pointer coordinates into the view's logical space, for the
1268     /// same reason as `resize` — a click at the bottom of a 2x window would
1269     /// otherwise land twice as far down the page as the cursor.
1270     fn to_logical(&self, x_px: f32, y_px: f32) -> (f64, f64) {
1271         ((x_px / self.scale) as f64, (y_px / self.scale) as f64)
1272     }
1273 }
1274 
1275 /// Copy an SHM buffer's pixels out for the image registry.
1276 ///
1277 /// `WPE_PIXEL_FORMAT_ARGB8888` is B,G,R,A in memory on little-endian, which
1278 /// is handed over **as BGRA** rather than swizzled: the sampler reads either
1279 /// channel order at no cost, and rearranging 35 MB of bytes per frame on the
1280 /// CPU cost 7.4 ms at this display's fullscreen size — most of a frame budget,
1281 /// spent on nothing.
1282 ///
1283 /// The destination comes from `cce_ui::vk::recycle_buffer`, so in the steady
1284 /// state this allocates nothing: a fresh frame-sized `Vec` per frame was
1285 /// another 4.5 ms, almost all of it zeroing and page faults rather than
1286 /// copying. What remains is one memcpy per row, and only when the stride
1287 /// forces it — a tight stride is copied whole.
1288 ///
1289 /// Called from `pump`, never from the frame callback: a buffer superseded
1290 /// before the next pump is never read at all.
1291 ///
1292 /// The stride is not assumed to equal `width * 4`.
1293 unsafe fn read_shm(buffer: *mut WPEBuffer) -> Option<(Vec<u8>, u32, u32)> {
1294     if g_type_check_instance_is_a(buffer as *mut GTypeInstance, wpe_buffer_shm_get_type()) == 0 {
1295         return None;
1296     }
1297     let shm = buffer as *mut WPEBufferSHM;
1298     let (w, h) = (
1299         wpe_buffer_get_width(buffer) as u32,
1300         wpe_buffer_get_height(buffer) as u32,
1301     );
1302     let mut len: u64 = 0;
1303     let src = g_bytes_get_data(wpe_buffer_shm_get_data(shm), &mut len as *mut u64) as *const u8;
1304     if src.is_null() || w == 0 || h == 0 {
1305         return None;
1306     }
1307     let stride = wpe_buffer_shm_get_stride(shm) as usize;
1308     let row = w as usize * 4;
1309     let need = row * h as usize;
1310     if (len as usize) < stride * (h as usize - 1) + row {
1311         return None;
1312     }
1313     let mut out = cce_ui::vk::recycle_buffer(need);
1314     if stride == row {
1315         std::ptr::copy_nonoverlapping(src, out.as_mut_ptr(), need);
1316     } else {
1317         for y in 0..h as usize {
1318             std::ptr::copy_nonoverlapping(src.add(y * stride), out.as_mut_ptr().add(y * row), row);
1319         }
1320     }
1321     Some((out, w, h))
1322 }
1323 
1324 unsafe fn from_cstr(p: *const c_char) -> Option<String> {
1325     (!p.is_null())
1326         .then(|| std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned())
1327         .filter(|s| !s.is_empty())
1328 }
1329 
1330 
1331 /// Same inverting stylesheet the Servo backend uses, and for the same reason:
1332 /// it is the only thing that darkens a page shipping a hardcoded white with no
1333 /// `prefers-color-scheme` rule to honour.
1334 const FORCE_DARK_CSS: &str = "\
1335 html { background-color: #ffffff !important; filter: invert(1) hue-rotate(180deg) !important; }
1336 img, video, picture, canvas, svg, iframe, embed, object,
1337 [style*=\"background-image\"], [style*=\"background:url\"] {
1338   filter: invert(1) hue-rotate(180deg) !important;
1339 }
1340 ";
1341 
1342 /// Serves a `cce:` page. Runs on the main thread, unlike the Servo handler
1343 /// which runs on fetch threads — the `Arc<Mutex<_>>` stores are shared with
1344 /// that backend and stay as they are.
1345 unsafe extern "C" fn on_cce_request(request: *mut WebKitURISchemeRequest, data: gpointer) {
1346     let protocol = &*(data as *const crate::pages::CceProtocol);
1347     let uri = from_cstr(webkit_uri_scheme_request_get_uri(request)).unwrap_or_default();
1348     match protocol.route(&uri) {
1349         Some(html) => {
1350             let len = html.len() as i64;
1351             let bytes = html.into_bytes().into_boxed_slice();
1352             let ptr = Box::into_raw(bytes) as *mut c_void;
1353             // The stream owns the buffer and frees it with g_free, so the box
1354             // is deliberately leaked into it rather than dropped here.
1355             let stream = g_memory_input_stream_new_from_data(ptr, len, Some(free_boxed));
1356             let ctype = cstr("text/html; charset=utf-8");
1357             webkit_uri_scheme_request_finish(request, stream, len, ctype.as_ptr());
1358             g_object_unref(stream as *mut _);
1359         }
1360         None => {
1361             let msg = cstr(&format!("no such cce: page: {uri}"));
1362             let err = g_error_new_literal(1, 0, msg.as_ptr());
1363             webkit_uri_scheme_request_finish_error(request, err);
1364             g_error_free(err);
1365         }
1366     }
1367 }
1368 
1369 unsafe extern "C" fn free_boxed(p: gpointer) {
1370     drop(Box::from_raw(p as *mut u8));
1371 }
1372 
1373 /// Shared with WebKit's download signals for the life of the process.
1374 struct DownloadCtx {
1375     downloads: std::sync::Arc<crate::downloads::Downloads>,
1376     started: Rc<Cell<bool>>,
1377 }
1378 
1379 /// Per-download state, owned by that download's own signal closures.
1380 struct OneDownload {
1381     ctx: Rc<DownloadCtx>,
1382     id: Cell<u64>,
1383 }
1384 
1385 unsafe extern "C" fn on_download_started(
1386     _session: *mut GObject,
1387     download: *mut WebKitDownload,
1388     data: gpointer,
1389 ) {
1390     let ctx = &*(data as *const DownloadCtx);
1391     let one = Rc::new(OneDownload {
1392         ctx: Rc::new(DownloadCtx {
1393             downloads: ctx.downloads.clone(),
1394             started: ctx.started.clone(),
1395         }),
1396         id: Cell::new(u64::MAX),
1397     });
1398     ctx.started.set(true);
1399 
1400     for (sig, cb) in [
1401         (
1402             "decide-destination",
1403             on_decide_destination as *const () as usize,
1404         ),
1405         ("received-data", on_received_data as *const () as usize),
1406         ("finished", on_finished as *const () as usize),
1407         ("failed", on_failed as *const () as usize),
1408     ] {
1409         let name = cstr(sig);
1410         g_signal_connect_data(
1411             download as *mut _,
1412             name.as_ptr(),
1413             Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
1414             Rc::into_raw(one.clone()) as gpointer,
1415             Some(drop_one_download),
1416             0,
1417         );
1418     }
1419 }
1420 
1421 unsafe extern "C" fn drop_one_download(data: gpointer, _c: *mut GClosure) {
1422     drop(Rc::from_raw(data as *const OneDownload));
1423 }
1424 
1425 /// WebKit asks where to put it, passing the name the *server* suggested —
1426 /// `Content-Disposition` when present, which the extension sniff could never
1427 /// see. Returning TRUE means we handled it.
1428 unsafe extern "C" fn on_decide_destination(
1429     download: *mut WebKitDownload,
1430     suggested: *const c_char,
1431     data: gpointer,
1432 ) -> gboolean {
1433     let one = &*(data as *const OneDownload);
1434     let name = from_cstr(suggested).unwrap_or_else(|| "download".into());
1435     let path = crate::downloads::Downloads::destination_for(&name);
1436 
1437     let total = {
1438         let response = webkit_download_get_response(download);
1439         (!response.is_null())
1440             .then(|| webkit_uri_response_get_content_length(response))
1441             .filter(|n| *n > 0)
1442     };
1443     let uri = from_cstr(webkit_download_get_destination(download)).unwrap_or_default();
1444     one.id
1445         .set(one.ctx.downloads.adopt(uri, path.clone(), total));
1446 
1447     let dest = cstr(&path.to_string_lossy());
1448     webkit_download_set_destination(download, dest.as_ptr());
1449     1
1450 }
1451 
1452 unsafe extern "C" fn on_received_data(
1453     download: *mut WebKitDownload,
1454     _len: u64,
1455     data: gpointer,
1456 ) {
1457     let one = &*(data as *const OneDownload);
1458     if one.id.get() != u64::MAX {
1459         one.ctx.downloads.set_progress(
1460             one.id.get(),
1461             webkit_download_get_received_data_length(download),
1462             None,
1463         );
1464     }
1465 }
1466 
1467 unsafe extern "C" fn on_finished(_d: *mut WebKitDownload, data: gpointer) {
1468     let one = &*(data as *const OneDownload);
1469     if one.id.get() != u64::MAX {
1470         one.ctx.downloads.set_finished(one.id.get(), Ok(()));
1471     }
1472 }
1473 
1474 unsafe extern "C" fn on_failed(_d: *mut WebKitDownload, error: *mut GError, data: gpointer) {
1475     let one = &*(data as *const OneDownload);
1476     let msg = (!error.is_null())
1477         .then(|| from_cstr((*error).message))
1478         .flatten()
1479         .unwrap_or_else(|| "download failed".into());
1480     if one.id.get() != u64::MAX {
1481         one.ctx.downloads.set_finished(one.id.get(), Err(msg));
1482     }
1483 }
1484 
1485 /// What a page is currently blocked on. At most one of each: WebKit will not
1486 /// raise a second dialog on the same view until the first is answered.
1487 #[derive(Default)]
1488 pub(super) struct Prompts {
1489     dialog: Option<(*mut WebKitScriptDialog, PendingDialog)>,
1490     auth: Option<(*mut WebKitAuthenticationRequest, PendingAuth)>,
1491     /// The page asked for a context menu; the chrome draws its own.
1492     context_menu: Option<ContextMenuInfo>,
1493     /// Login fields the account watcher reported, oldest first. A queue and
1494     /// not a slot: a blur followed by a focus is two different states, and
1495     /// collapsing them would leave the list open over the wrong field.
1496     form_events: std::collections::VecDeque<crate::wpe::formwatch::FormEvent>,
1497 }
1498 
1499 /// What was under the pointer when the page asked for a context menu, read
1500 /// off WebKit's hit test. The chrome builds its menu from this.
1501 #[derive(Debug, Clone, Default)]
1502 pub struct ContextMenuInfo {
1503     /// `(uri, label)` when the hit was a link.
1504     pub link: Option<(String, Option<String>)>,
1505     pub image_uri: Option<String>,
1506     pub is_selection: bool,
1507     pub is_editable: bool,
1508 }
1509 
1510 /// A page's `alert` / `confirm` / `prompt`, waiting on the chrome.
1511 #[derive(Debug, Clone)]
1512 pub struct PendingDialog {
1513     pub message: String,
1514     /// `Some` for `prompt`, carrying its default text; `None` otherwise.
1515     pub prompt_default: Option<String>,
1516     /// `confirm` and `beforeunload` offer a choice; `alert` only acknowledges.
1517     pub has_cancel: bool,
1518 }
1519 
1520 /// An HTTP auth challenge, waiting on the chrome.
1521 #[derive(Debug, Clone)]
1522 pub struct PendingAuth {
1523     pub host: String,
1524     pub realm: String,
1525     /// Set when the previous credentials were rejected — worth telling the
1526     /// user, since the field otherwise looks identical to the first attempt.
1527     pub retry: bool,
1528 }
1529 
1530 unsafe fn connect_raw(
1531     wv: *mut WebKitWebView,
1532     signal: &str,
1533     cb: usize,
1534     prompts: &Rc<RefCell<Prompts>>,
1535 ) {
1536     let name = cstr(signal);
1537     g_signal_connect_data(
1538         wv as *mut _,
1539         name.as_ptr(),
1540         Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
1541         Rc::into_raw(prompts.clone()) as gpointer,
1542         Some(drop_prompts_ref),
1543         0,
1544     );
1545 }
1546 
1547 /// A message from the account watcher. Anything that does not parse as one of
1548 /// its events is dropped without comment — this is a channel the chrome acts
1549 /// on, so it accepts only what it recognizes.
1550 unsafe extern "C" fn on_account_message(
1551     _ucm: *mut WebKitUserContentManager,
1552     value: *mut JSCValue,
1553     data: gpointer,
1554 ) {
1555     let prompts = &*(data as *const RefCell<Prompts>);
1556     let raw = jsc_value_to_string(value);
1557     let Some(json) = from_cstr(raw) else { return };
1558     g_free(raw as *mut _);
1559     if let Some(event) = super::formwatch::parse_event(&json) {
1560         let mut p = prompts.borrow_mut();
1561         // A page that spins on scroll must not grow this without bound; the
1562         // chrome only ever cares about the last few.
1563         if p.form_events.len() > 8 {
1564             p.form_events.pop_front();
1565         }
1566         p.form_events.push_back(event);
1567     }
1568 }
1569 
1570 unsafe extern "C" fn drop_prompts_ref(data: gpointer, _c: *mut GClosure) {
1571     drop(Rc::from_raw(data as *const RefCell<Prompts>));
1572 }
1573 
1574 /// Returning TRUE means *we* will answer. The dialog is reffed and held; the
1575 /// page stays blocked until `respond_dialog` closes it.
1576 unsafe extern "C" fn on_script_dialog(
1577     _wv: *mut WebKitWebView,
1578     dialog: *mut WebKitScriptDialog,
1579     data: gpointer,
1580 ) -> gboolean {
1581     let prompts = &*(data as *const RefCell<Prompts>);
1582     let kind = webkit_script_dialog_get_dialog_type(dialog);
1583     let message = from_cstr(webkit_script_dialog_get_message(dialog)).unwrap_or_default();
1584     let is_prompt = kind == WebKitScriptDialogType::WEBKIT_SCRIPT_DIALOG_PROMPT;
1585     let pending = PendingDialog {
1586         message,
1587         prompt_default: is_prompt
1588             .then(|| from_cstr(webkit_script_dialog_prompt_get_default_text(dialog)))
1589             .flatten()
1590             .or_else(|| is_prompt.then(String::new)),
1591         has_cancel: kind != WebKitScriptDialogType::WEBKIT_SCRIPT_DIALOG_ALERT,
1592     };
1593     webkit_script_dialog_ref(dialog);
1594     prompts.borrow_mut().dialog = Some((dialog, pending));
1595     1
1596 }
1597 
1598 /// Same contract: TRUE means we answer, and the request is reffed until we do.
1599 unsafe extern "C" fn on_authenticate(
1600     _wv: *mut WebKitWebView,
1601     request: *mut WebKitAuthenticationRequest,
1602     data: gpointer,
1603 ) -> gboolean {
1604     let prompts = &*(data as *const RefCell<Prompts>);
1605     let pending = PendingAuth {
1606         host: from_cstr(webkit_authentication_request_get_host(request)).unwrap_or_default(),
1607         realm: from_cstr(webkit_authentication_request_get_realm(request)).unwrap_or_default(),
1608         retry: webkit_authentication_request_is_retry(request) != 0,
1609     };
1610     g_object_ref(request as *mut _);
1611     prompts.borrow_mut().auth = Some((request, pending));
1612     1
1613 }
1614 
1615 /// The page asked for a context menu. Stash what the hit test says was under
1616 /// the pointer and claim presentation; the chrome draws the menu at the
1617 /// pointer position it already tracks (the hit test carries no coordinates).
1618 unsafe extern "C" fn on_context_menu(
1619     _wv: *mut WebKitWebView,
1620     _menu: *mut WebKitContextMenu,
1621     hit: *mut WebKitHitTestResult,
1622     data: gpointer,
1623 ) -> gboolean {
1624     let prompts = &*(data as *const RefCell<Prompts>);
1625     let mut info = ContextMenuInfo::default();
1626     if !hit.is_null() {
1627         if webkit_hit_test_result_context_is_link(hit) != 0 {
1628             if let Some(uri) = from_cstr(webkit_hit_test_result_get_link_uri(hit)) {
1629                 info.link = Some((uri, from_cstr(webkit_hit_test_result_get_link_label(hit))));
1630             }
1631         }
1632         if webkit_hit_test_result_context_is_image(hit) != 0 {
1633             info.image_uri = from_cstr(webkit_hit_test_result_get_image_uri(hit));
1634         }
1635         info.is_selection = webkit_hit_test_result_context_is_selection(hit) != 0;
1636         info.is_editable = webkit_hit_test_result_context_is_editable(hit) != 0;
1637     }
1638     prompts.borrow_mut().context_menu = Some(info);
1639     1
1640 }