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

src/wpe/host.rs (114.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     /// The tab's WebProcess died, and why (`WebKitWebProcessTerminationReason`).
  45     /// Set by the signal, taken by `pump`, which puts the error page up —
  46     /// outside the signal, so the load is not started from inside WebKit's
  47     /// own teardown of the process.
  48     terminated: Cell<Option<WebKitWebProcessTerminationReason::Type>>,
  49     /// WebKit's responsiveness timer gave up on the WebProcess: a message
  50     /// has gone ~3s without an answer. Cleared when it answers again.
  51     unresponsive: Cell<bool>,
  52     /// The person chose to wait on this hang, so it is not asked about again
  53     /// until the page recovers and hangs anew.
  54     hang_waived: Cell<bool>,
  55     /// When the outstanding [`WebKitHost::ping`] went out, if one has not
  56     /// been answered yet.
  57     ping_since: Cell<Option<std::time::Instant>>,
  58     /// The process is being stopped as a deadlock, so its termination should
  59     /// reload the page rather than show the error page.
  60     auto_reload: Cell<bool>,
  61     /// When this tab was last recovered that way.
  62     last_auto: Cell<Option<std::time::Instant>>,
  63 }
  64 
  65 /// How long every page process must sit idle while the active tab is
  66 /// unresponsive before the hang is taken for a deadlock and recovered
  67 /// without asking.
  68 const DEADLOCK_WATCH: std::time::Duration = std::time::Duration::from_secs(5);
  69 
  70 /// A tab is recovered automatically at most this often. A page that
  71 /// deadlocks on every load is left to the prompt instead of reloading in a
  72 /// loop.
  73 const AUTO_RECOVER_GAP: std::time::Duration = std::time::Duration::from_secs(120);
  74 
  75 /// A deadlock in progress: when the watch began, on which tab, and every
  76 /// page process's CPU time at that moment.
  77 struct DeadlockWatch {
  78     tab: Rc<TabState>,
  79     since: std::time::Instant,
  80     ticks: std::collections::HashMap<i32, u64>,
  81 }
  82 
  83 /// CPU time (user + system, in `/proc` clock ticks) of every WPEWebProcess
  84 /// below this one — they sit under bubblewrap, so not as direct children.
  85 ///
  86 /// WebKit has no API that says which process serves which tab, so the
  87 /// watch reads all of them. That is what makes it conservative: one busy
  88 /// process anywhere is enough to leave the hang to the prompt.
  89 fn web_process_ticks() -> std::collections::HashMap<i32, u64> {
  90     let me = std::process::id() as i32;
  91     let stat = |pid: i32| std::fs::read_to_string(format!("/proc/{pid}/stat")).ok();
  92     // The fields after the parenthesized command name, which may hold spaces.
  93     let fields = |s: &str| -> Vec<String> {
  94         s.rsplit_once(')').map_or_else(Vec::new, |(_, rest)| {
  95             rest.split_whitespace().map(str::to_string).collect()
  96         })
  97     };
  98     let mut out = std::collections::HashMap::new();
  99     for entry in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
 100         let Ok(pid) = entry.file_name().to_string_lossy().parse::<i32>() else { continue };
 101         let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default();
 102         if comm.trim() != "WPEWebProcess" {
 103             continue;
 104         }
 105         let Some(s) = stat(pid) else { continue };
 106         let f = fields(&s);
 107         let mut parent = f.get(1).and_then(|p| p.parse::<i32>().ok());
 108         let mut ours = false;
 109         for _ in 0..4 {
 110             match parent {
 111                 Some(p) if p == me => {
 112                     ours = true;
 113                     break;
 114                 }
 115                 Some(p) if p > 1 => {
 116                     parent = stat(p).and_then(|s| fields(&s).get(1).and_then(|p| p.parse().ok()));
 117                 }
 118                 _ => break,
 119             }
 120         }
 121         // utime and stime are fields 14 and 15; `f` starts at field 3.
 122         let ticks = |i: usize| f.get(i).and_then(|t| t.parse::<u64>().ok()).unwrap_or(0);
 123         if ours {
 124             out.insert(pid, ticks(11) + ticks(12));
 125         }
 126     }
 127     out
 128 }
 129 
 130 /// How long a page may leave a ping unanswered before it counts as hung —
 131 /// WebKit's own responsiveness timeout.
 132 const HANG_GRACE: std::time::Duration = std::time::Duration::from_secs(3);
 133 
 134 /// The world the ping runs in, so the page never sees it.
 135 const PING_WORLD: &str = "cce-ping";
 136 
 137 /// One tab: its webview plus the app-visible page state and the last frame
 138 /// uploaded to the image registry (id, w px, h px). Same shape as
 139 /// `webview::Tab` so the chrome reads it identically.
 140 pub struct Tab {
 141     webview: *mut WebKitWebView,
 142     view: *mut WPEView,
 143     state: Rc<TabState>,
 144     pub title: Option<String>,
 145     pub url: Option<Url>,
 146     pub loading: bool,
 147     image: Option<(u32, u32, u32)>,
 148     /// Under `CCE_BROWSER_DAMAGE_CHECK` only: the picture `image` should
 149     /// hold, patched region by region alongside it.
 150     mirror: Option<Vec<u8>>,
 151 }
 152 
 153 impl Drop for Tab {
 154     fn drop(&mut self) {
 155         // Unref the webview *first*: destroying it runs the closures'
 156         // destroy-notify, which releases their `Rc<TabState>` refs. Dropping
 157         // the state before the object that can still emit into it would be a
 158         // use-after-free.
 159         unsafe { g_object_unref(self.webview as *mut _) };
 160         if let Some((id, ..)) = self.image {
 161             cce_ui::vk::free_image(id);
 162         }
 163     }
 164 }
 165 
 166 /// `notify::` handler shared by title / uri / is-loading: read the property
 167 /// straight back off the emitting webview and stash it.
 168 unsafe extern "C" fn on_notify(
 169     obj: *mut GObject,
 170     _pspec: *mut GParamSpec,
 171     data: gpointer,
 172 ) {
 173     let st = &*(data as *const TabState);
 174     let wv = obj as *mut WebKitWebView;
 175     *st.title.borrow_mut() = from_cstr(webkit_web_view_get_title(wv));
 176     if let Some(u) = from_cstr(webkit_web_view_get_uri(wv)).and_then(|u| Url::parse(&u).ok()) {
 177         *st.url.borrow_mut() = Some(u);
 178     }
 179     st.loading.set(webkit_web_view_is_loading(wv) != 0);
 180     st.dirty.set(true);
 181 }
 182 
 183 /// Releases the `Rc` ref a connection owned, when the closure is destroyed.
 184 unsafe extern "C" fn drop_state_ref(data: gpointer, _closure: *mut GClosure) {
 185     drop(Rc::from_raw(data as *const TabState));
 186 }
 187 
 188 unsafe fn connect_notify(wv: *mut WebKitWebView, signal: &str, state: &Rc<TabState>) {
 189     connect_state(wv, signal, on_notify as *const () as usize, state);
 190 }
 191 
 192 /// Connect `cb` with a `TabState` as its data.
 193 unsafe fn connect_state(wv: *mut WebKitWebView, signal: &str, cb: usize, state: &Rc<TabState>) {
 194     let name = cstr(signal);
 195     // Each connection owns its own ref, handed back by `drop_state_ref`.
 196     let raw = Rc::into_raw(state.clone()) as gpointer;
 197     g_signal_connect_data(
 198         wv as *mut _,
 199         name.as_ptr(),
 200         Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
 201         raw,
 202         Some(drop_state_ref),
 203         0,
 204     );
 205 }
 206 
 207 /// The tab's WebProcess is gone — crashed, killed for memory, or stopped by
 208 /// [`WebKitHost::stop_unresponsive`]. Without this the tab just froze on its
 209 /// last frame, with nothing saying the page behind it no longer existed.
 210 unsafe extern "C" fn on_terminated(
 211     _wv: *mut WebKitWebView,
 212     reason: WebKitWebProcessTerminationReason::Type,
 213     data: gpointer,
 214 ) {
 215     let st = &*(data as *const TabState);
 216     st.terminated.set(Some(reason));
 217     st.unresponsive.set(false);
 218     st.hang_waived.set(false);
 219     st.ping_since.set(None);
 220     st.dirty.set(true);
 221 }
 222 
 223 /// The ping came back — or failed because the process is gone, which the
 224 /// termination signal reports on its own.
 225 unsafe extern "C" fn on_ping(source: *mut GObject, res: *mut GAsyncResult, data: gpointer) {
 226     let st = Rc::from_raw(data as *const TabState);
 227     let mut err: *mut GError = std::ptr::null_mut();
 228     let v = webkit_web_view_evaluate_javascript_finish(source as *mut WebKitWebView, res, &mut err);
 229     if !v.is_null() {
 230         g_object_unref(v as *mut _);
 231     }
 232     if !err.is_null() {
 233         g_error_free(err);
 234     }
 235     st.ping_since.set(None);
 236     if !st.unresponsive.get() {
 237         st.hang_waived.set(false);
 238     }
 239 }
 240 
 241 /// Fires once the grace has run out. It does nothing itself: being a GLib
 242 /// source is what wakes the loop, and the `pump` that follows is where an
 243 /// unanswered ping is noticed. Without it, a hung page — which sends nothing
 244 /// — would leave the loop asleep and the question unasked.
 245 unsafe extern "C" fn on_ping_due(_data: gpointer) {}
 246 
 247 unsafe extern "C" fn on_responsive(obj: *mut GObject, _pspec: *mut GParamSpec, data: gpointer) {
 248     let st = &*(data as *const TabState);
 249     let responsive = webkit_web_view_get_is_web_process_responsive(obj as *mut WebKitWebView) != 0;
 250     st.unresponsive.set(!responsive);
 251     if responsive {
 252         st.hang_waived.set(false);
 253     }
 254 }
 255 
 256 /// The page shown in place of one whose WebProcess died. Loaded as
 257 /// *alternate* HTML for the dead page's own URL, so the URL bar, the saved
 258 /// session and Reload all still mean the real page.
 259 fn terminated_page(url: Option<&Url>, reason: WebKitWebProcessTerminationReason::Type) -> String {
 260     use crate::pages::{html_escape, page};
 261     use WebKitWebProcessTerminationReason::*;
 262     let (title, why) = match reason {
 263         WEBKIT_WEB_PROCESS_EXCEEDED_MEMORY_LIMIT => (
 264             "This page ran out of memory",
 265             "Its renderer used more memory than it is allowed and was stopped.",
 266         ),
 267         WEBKIT_WEB_PROCESS_TERMINATED_BY_API => (
 268             "This page was stopped",
 269             "Its renderer had stopped responding, and was shut down.",
 270         ),
 271         _ => ("This page crashed", "Its renderer exited unexpectedly."),
 272     };
 273     let meta = match url {
 274         Some(u) => {
 275             let u = html_escape(u.as_str());
 276             format!("{u}<a href=\"{u}\">Reload</a>")
 277         }
 278         None => String::new(),
 279     };
 280     page(title, &meta, &format!("<p class=empty>{why}</p>"), "")
 281 }
 282 
 283 /// The frame handed over by `render_buffer`, drained by `pump`. A slot, not a
 284 /// queue: only the newest frame is ever shown, and the engine will not run far
 285 /// ahead of a browser that has not released the one it is holding.
 286 /// Counters behind `CCE_BROWSER_FRAME_DEBUG=1`: how many frames the engine
 287 /// finished against how many were actually read back. The gap between them is
 288 /// what pacing saves, and it is invisible from the outside — a browser that
 289 /// skips nine frames in ten looks exactly like one that copies all ten.
 290 #[derive(Default)]
 291 struct FrameCounts {
 292     produced: u64,
 293     read: u64,
 294     /// Of `read`, how many copied only their damage.
 295     partial: u64,
 296     /// Bytes copied out of the engine's buffers.
 297     bytes: u64,
 298 }
 299 
 300 /// Read whole frames only, ignoring damage. The escape hatch if a page is
 301 /// ever drawn stale; `CCE_BROWSER_DAMAGE_CHECK` is how to find out.
 302 fn full_frames() -> bool {
 303     static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
 304     *ON.get_or_init(|| std::env::var_os("CCE_BROWSER_FULL_FRAMES").is_some())
 305 }
 306 
 307 /// Check every region read against the whole frame: each tab keeps a CPU
 308 /// copy of its picture, patched exactly as its image is, and any pixel that
 309 /// disagrees with the engine's buffer is logged. Costs a full copy and a
 310 /// compare per frame, so it is a test switch, not a mode.
 311 fn damage_check() -> bool {
 312     static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
 313     *ON.get_or_init(|| std::env::var_os("CCE_BROWSER_DAMAGE_CHECK").is_some())
 314 }
 315 
 316 fn frame_debug() -> bool {
 317     static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
 318     *ON.get_or_init(|| std::env::var_os("CCE_BROWSER_FRAME_DEBUG").is_some())
 319 }
 320 
 321 #[derive(Default)]
 322 struct Pending {
 323     /// The newest finished buffer the engine has handed over, still unread.
 324     /// Read and released at the next `pump`; superseded by a newer one, which
 325     /// hands this one back **unread** — that skipped copy is the whole point
 326     /// of holding it rather than copying in the callback.
 327     held: Option<(*mut WPEView, *mut WPEBuffer)>,
 328     /// Per view, what its frames changed since the last one that was read —
 329     /// including every frame handed back unread in between, whose changes
 330     /// the next readback still has to carry. No entry: nothing changed.
 331     damage: std::collections::HashMap<usize, super::damage::Damage>,
 332     counts: FrameCounts,
 333     /// When the counters were last reported.
 334     reported: Option<std::time::Instant>,
 335 }
 336 
 337 pub struct WebKitHost {
 338     display: *mut WPEDisplay,
 339     toplevel: *mut WPEToplevel,
 340     tabs: Vec<Tab>,
 341     active: usize,
 342     size_px: (u32, u32),
 343     scale: f32,
 344     pending: Rc<std::cell::RefCell<Pending>>,
 345     /// GLib's pollfd set, mirrored into one epoll fd for calloop.
 346     poll: Option<GlibPoll>,
 347     /// Shared with the `cce:` pages, exactly as `ServoHost` holds them —
 348     /// bookmarks and history are app state, not engine state, so they cross
 349     /// the backend swap unchanged.
 350     history: std::sync::Arc<crate::pages::History>,
 351     bookmarks: std::sync::Arc<crate::pages::Bookmarks>,
 352     favorites: std::sync::Arc<crate::pages::Favorites>,
 353     history_enabled: bool,
 354     force_dark: bool,
 355     /// Serves the `cce:` pages. Boxed and leaked into the scheme callback,
 356     /// so it must outlive every webview.
 357     protocol: Rc<crate::pages::CceProtocol>,
 358     downloads: std::sync::Arc<crate::downloads::Downloads>,
 359     clear_cookies: std::sync::Arc<std::sync::atomic::AtomicBool>,
 360     session: *mut WebKitNetworkSession,
 361     download_started: Rc<Cell<bool>>,
 362     /// A page asked something and is blocked until we answer.
 363     prompts: Rc<RefCell<Prompts>>,
 364     /// A frame has been uploaded that nothing has drawn yet.
 365     ///
 366     /// The readback is paced by this: while it is set, a finished buffer is
 367     /// left *held* instead of being copied over a picture nobody saw — and
 368     /// since a held buffer is not yet acknowledged, the engine waits on it
 369     /// too (see `frame_drawn`).
 370     pending_draw: Cell<bool>,
 371     /// The injected account watcher, kept so the setting can take it away
 372     /// again. `None` when account autocomplete is off, which is also when no
 373     /// page carries the script at all.
 374     watcher: Option<*mut WebKitUserScript>,
 375     /// Retained only so tests can assert on rendered output; the registry
 376     /// owns the copy that actually gets drawn.
 377     /// Top-left pixel of the last frame — three bytes, not the frame.
 378     last_pixel: Option<(u8, u8, u8)>,
 379     /// Installed on every webview when force-dark is on.
 380     ucm: *mut WebKitUserContentManager,
 381     /// A pre-built hidden webview parked on about:blank, WebProcess already
 382     /// spawned. `open_tab` adopts it and pays only the navigation — measured
 383     /// at ~65ms to a live internal page against ~250ms building from scratch
 384     /// (~200ms of which is webview creation + process spawn). The price is
 385     /// one idle WebProcess held per window. Theme changes reach it anyway:
 386     /// the colour scheme is display-level and force-dark lives in the shared
 387     /// user-content-manager it was built with.
 388     spare: Option<(*mut WebKitWebView, *mut WPEView, Rc<TabState>)>,
 389     /// Whether the window holds keyboard focus, as last told by [`Self::focus`].
 390     /// Kept so a tab made active later inherits it: focus belongs to the view,
 391     /// and only the active tab's view should have it.
 392     window_focused: bool,
 393     /// The pointer buttons the page is holding, as `WPE_MODIFIER_POINTER_*`
 394     /// bits, stamped on every pointer event.
 395     ///
 396     /// WebKit reads a drag off the *move* event's own modifiers, not off the
 397     /// press it saw earlier: a move reporting no held button is a hover, so
 398     /// press-drag-release over text selected nothing (and dragged nothing)
 399     /// while every move went out with an empty mask.
 400     held_buttons: Cell<WPEModifiers::Type>,
 401     /// A hang being watched to see whether it is a deadlock.
 402     deadlock_watch: Option<DeadlockWatch>,
 403     /// The injected vi focus watcher; `None` while vi mode is off, which is
 404     /// also when no page carries it.
 405     vi_watcher: Option<*mut WebKitUserScript>,
 406 }
 407 
 408 unsafe fn cstr(s: &str) -> CString {
 409     CString::new(s).expect("no interior nul")
 410 }
 411 
 412 impl WebKitHost {
 413     /// Boot WPE and open the first tab.
 414     ///
 415     /// One host per process: the frame sink and the GType registrations are
 416     /// process-wide. That matches the app (one browser window per process)
 417     /// but is worth knowing before writing a test that builds two.
 418     pub fn new(url: Url, size_px: (u32, u32)) -> Self {
 419         unsafe {
 420             let t = types();
 421             let display = g_object_new(t.display, std::ptr::null::<c_char>()) as *mut WPEDisplay;
 422             let mut err: *mut GError = std::ptr::null_mut();
 423             assert!(
 424                 wpe_display_connect(display, &mut err) != 0,
 425                 "wpe_display_connect failed"
 426             );
 427 
 428             // Persisted profile. The data directory persists website data
 429             // (localStorage, IndexedDB, service workers) on its own, but the
 430             // cookie store stays memory-only until it is explicitly given a
 431             // file — the set_persistent_storage call below, without which
 432             // every launch starts logged out of every site even though the
 433             // rest of the profile survives. Same location and the same 0700
 434             // reasoning as the Servo backend — the jar holds live sessions.
 435             let profile = crate::pages::state_dir().join("profile");
 436             let _ = std::fs::create_dir_all(&profile);
 437             {
 438                 use std::os::unix::fs::PermissionsExt;
 439                 let _ = std::fs::set_permissions(&profile, std::fs::Permissions::from_mode(0o700));
 440             }
 441             let (data_dir, cache_dir) = (
 442                 cstr(&profile.to_string_lossy()),
 443                 cstr(&profile.join("cache").to_string_lossy()),
 444             );
 445             let session = webkit_network_session_new(data_dir.as_ptr(), cache_dir.as_ptr());
 446             let cookie_db = cstr(&profile.join("cookies.sqlite").to_string_lossy());
 447             webkit_cookie_manager_set_persistent_storage(
 448                 webkit_network_session_get_cookie_manager(session),
 449                 cookie_db.as_ptr(),
 450                 WebKitCookiePersistentStorage::WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE,
 451             );
 452 
 453             let history = std::sync::Arc::new(crate::pages::History::load());
 454             let bookmarks = std::sync::Arc::new(crate::pages::Bookmarks::load());
 455             let favorites = std::sync::Arc::new(crate::pages::Favorites::load());
 456             let downloads = std::sync::Arc::new(crate::downloads::Downloads::default());
 457             let clear_cookies =
 458                 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
 459             let protocol = Rc::new(crate::pages::CceProtocol {
 460                 history: history.clone(),
 461                 bookmarks: bookmarks.clone(),
 462                 favorites: favorites.clone(),
 463                 downloads: downloads.clone(),
 464                 clear_cookies: clear_cookies.clone(),
 465             });
 466 
 467             // The `cce:` scheme, served straight out of the app exactly as the
 468             // Servo backend serves it — same routing table, so the pages and
 469             // their mutating links behave identically on both engines.
 470             let ctx = webkit_web_context_get_default();
 471             let scheme = cstr("cce");
 472             webkit_web_context_register_uri_scheme(
 473                 ctx,
 474                 scheme.as_ptr(),
 475                 Some(on_cce_request),
 476                 Rc::into_raw(protocol.clone()) as gpointer,
 477                 None,
 478             );
 479 
 480             let download_started = Rc::new(Cell::new(false));
 481 
 482             // WebKit fetches downloads itself, and decides what *is* one by
 483             // content type — so the extension sniff `is_download_url` exists
 484             // for is simply not needed here, and neither is the argv/URL-bar
 485             // blind spot it created.
 486             let ctxs = Rc::new(DownloadCtx {
 487                 downloads: downloads.clone(),
 488                 started: download_started.clone(),
 489             });
 490             let sig = cstr("download-started");
 491             g_signal_connect_data(
 492                 session as *mut _,
 493                 sig.as_ptr(),
 494                 Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
 495                     on_download_started
 496                         as unsafe extern "C" fn(*mut GObject, *mut WebKitDownload, gpointer),
 497                 )),
 498                 Rc::into_raw(ctxs) as gpointer,
 499                 None,
 500                 0,
 501             );
 502 
 503             let prompts = Rc::new(RefCell::new(Prompts::default()));
 504             let pending = Rc::new(std::cell::RefCell::new(Pending::default()));
 505             let sink = pending.clone();
 506             FRAME_SINK = Some(Box::new(move |view: *mut WPEView, buffer: *mut WPEBuffer, damage: &[(i32, i32, i32, i32)]| {
 507                 let mut slot = sink.borrow_mut();
 508                 slot.damage
 509                     .entry(view as usize)
 510                     .or_insert_with(|| super::damage::Damage::Rects(Vec::new()))
 511                     .add(damage);
 512                 // Replace, never accumulate: the newest frame wins. The one it
 513                 // supersedes goes back to the engine **without being read** —
 514                 // several frames can be dispatched inside a single pump's
 515                 // drain, and only the last of them will ever be shown, so the
 516                 // rest are not worth 35 MB of copying each.
 517                 // It will never be shown, so it is as done as it will get:
 518                 // say both halves, or that view composites nothing again.
 519                 if let Some((old_view, old_buffer)) = slot.held.replace((view, buffer)) {
 520                     wpe_view_buffer_rendered(old_view, old_buffer);
 521                     wpe_view_buffer_released(old_view, old_buffer);
 522                 }
 523                 if frame_debug() {
 524                     slot.counts.produced += 1;
 525                 }
 526                 true
 527             }));
 528 
 529             // `0` is no view limit, and every tab's view must fit: a full
 530             // toplevel refuses `wpe_view_set_toplevel` *silently*. At `1`
 531             // (the spike's value) only the first tab ever attached, and every
 532             // later one ran without the window's scale (rendering at half
 533             // resolution on a 2x output) or its ACTIVE state (no text caret).
 534             let toplevel = wpe_display_create_toplevel(display, 0);
 535             // Scale is 1 until the first `resize` from a real window; the
 536             // constructor's size is already logical.
 537             wpe_toplevel_resized(toplevel, size_px.0 as i32, size_px.1 as i32);
 538 
 539             let mut host = Self {
 540                 display,
 541                 toplevel,
 542                 tabs: Vec::new(),
 543                 active: usize::MAX, // sentinel: force activate() to do the work
 544                 size_px,
 545                 scale: 1.0,
 546                 pending,
 547                 poll: GlibPoll::new()
 548                     .map_err(|e| log::warn!("no GLib epoll bridge ({e}); pump will poll"))
 549                     .ok(),
 550                 history: history.clone(),
 551                 bookmarks: bookmarks.clone(),
 552                 favorites,
 553                 history_enabled: true,
 554                 force_dark: false,
 555                 protocol,
 556                 downloads,
 557                 clear_cookies,
 558                 session,
 559                 download_started,
 560                 pending_draw: Cell::new(false),
 561                 prompts,
 562                 last_pixel: None,
 563                 ucm: webkit_user_content_manager_new(),
 564                 watcher: None,
 565                 spare: None,
 566                 window_focused: false,
 567                 held_buttons: Cell::new(0),
 568                 deadlock_watch: None,
 569                 vi_watcher: None,
 570             };
 571             // The account watcher's channel, in its own script world. Both
 572             // halves are registered here, once, on the shared content
 573             // manager every tab is built against.
 574             host.register_account_channel();
 575             host.register_vi_channel();
 576             // Which page fields want no on-screen keyboard (`ime.rs`).
 577             super::ime::install_watch(host.ucm);
 578             host.open_tab(url);
 579             host
 580         }
 581     }
 582 
 583     /// Listen for the account watcher's messages, in its private world.
 584     ///
 585     /// The world is the security boundary: page script cannot post on a
 586     /// channel registered for another world, so a message arriving here came
 587     /// from the injected watcher and not from the page pretending to be one.
 588     fn register_account_channel(&self) {
 589         use super::formwatch;
 590         unsafe {
 591             let name = cstr(formwatch::CHANNEL);
 592             let world = cstr(formwatch::WORLD);
 593             if webkit_user_content_manager_register_script_message_handler(
 594                 self.ucm,
 595                 name.as_ptr(),
 596                 world.as_ptr(),
 597             ) == 0
 598             {
 599                 log::warn!("could not register the account message channel");
 600                 return;
 601             }
 602             let signal = cstr(&format!("script-message-received::{}", formwatch::CHANNEL));
 603             g_signal_connect_data(
 604                 self.ucm as *mut _,
 605                 signal.as_ptr(),
 606                 Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
 607                     on_account_message as *const () as usize,
 608                 )),
 609                 Rc::into_raw(self.prompts.clone()) as gpointer,
 610                 Some(drop_prompts_ref),
 611                 0,
 612             );
 613 
 614             // The fill asks: a channel with a reply, so a credential goes back
 615             // to exactly the frame that asked. Same world, same guarantee —
 616             // page script cannot ask on it.
 617             let name = cstr(formwatch::FILL_CHANNEL);
 618             if webkit_user_content_manager_register_script_message_handler_with_reply(
 619                 self.ucm,
 620                 name.as_ptr(),
 621                 world.as_ptr(),
 622             ) == 0
 623             {
 624                 log::warn!("could not register the account fill channel");
 625                 return;
 626             }
 627             let signal = cstr(&format!(
 628                 "script-message-with-reply-received::{}",
 629                 formwatch::FILL_CHANNEL
 630             ));
 631             g_signal_connect_data(
 632                 self.ucm as *mut _,
 633                 signal.as_ptr(),
 634                 Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
 635                     on_fill_ask as *const () as usize,
 636                 )),
 637                 Rc::into_raw(self.prompts.clone()) as gpointer,
 638                 Some(drop_prompts_ref),
 639                 0,
 640             );
 641         }
 642     }
 643 
 644     /// Install or remove the login-field watcher — the whole page-side
 645     /// footprint of the feature, so a browser with accounts turned off
 646     /// injects nothing at all.
 647     pub fn set_accounts_enabled(&mut self, on: bool) {
 648         use super::formwatch;
 649         unsafe {
 650             match (on, self.watcher.take()) {
 651                 (true, None) => {
 652                     let source = cstr(formwatch::WATCH_JS);
 653                     let world = cstr(formwatch::WORLD);
 654                     // Every frame — sign-in forms are often a frame of their
 655                     // own — and at document start, so the listeners are in
 656                     // place before a login page's own script runs.
 657                     let script = webkit_user_script_new_for_world(
 658                         source.as_ptr(),
 659                         WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
 660                         WebKitUserScriptInjectionTime::WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
 661                         world.as_ptr(),
 662                         std::ptr::null(),
 663                         std::ptr::null(),
 664                     );
 665                     webkit_user_content_manager_add_script(self.ucm, script);
 666                     self.watcher = Some(script);
 667                 }
 668                 (false, Some(script)) => {
 669                     webkit_user_content_manager_remove_script(self.ucm, script);
 670                     webkit_user_script_unref(script);
 671                     self.drop_fill_asks();
 672                 }
 673                 // Already in the asked-for state; `take` above is why the
 674                 // enabled case has to put its handle back.
 675                 (true, Some(script)) => self.watcher = Some(script),
 676                 (false, None) => {}
 677             }
 678         }
 679     }
 680 
 681     /// The next login-field event the watcher reported.
 682     pub fn take_form_event(&self) -> Option<super::formwatch::FormEvent> {
 683         self.prompts.borrow_mut().form_events.pop_front()
 684     }
 685 
 686     /// Drop anything the watcher reported for a page that is going away, so a
 687     /// stale focus cannot open a list over the next one.
 688     pub fn clear_form_events(&self) {
 689         self.prompts.borrow_mut().form_events.clear();
 690     }
 691 
 692     /// Put a picked account into the login fields of the document `frame`.
 693     ///
 694     /// Answers that document's fill ask with the credential, as data on the
 695     /// reply — the watcher fills its own recorded fields. Returns false, and
 696     /// sends nothing, when that document has no ask open: it navigated, a
 697     /// newer ask displaced it, or the tab was switched away from.
 698     pub fn fill_credentials(&self, frame: &str, username: &str, password: &str) -> bool {
 699         let reply = {
 700             let mut p = self.prompts.borrow_mut();
 701             let Some(at) = p.fill_asks.iter().position(|(t, _)| t == frame) else {
 702                 return false;
 703             };
 704             p.fill_asks.remove(at).map(|(_, r)| r)
 705         };
 706         let Some(reply) = reply else { return false };
 707         let answer = super::formwatch::fill_reply(username, password);
 708         unsafe { answer_fill(reply, Some(&answer)) };
 709         true
 710     }
 711 
 712     /// Answer every open fill ask with nothing. On a tab switch and on a
 713     /// navigation: whatever field asked is no longer the one on screen.
 714     pub fn drop_fill_asks(&self) {
 715         let asks: Vec<_> = self.prompts.borrow_mut().fill_asks.drain(..).collect();
 716         for (_, reply) in asks {
 717             unsafe { answer_fill(reply, None) };
 718         }
 719     }
 720 
 721     // ---- vi mode ----
 722 
 723     /// Listen for the vi focus watcher, in its own private world — the same
 724     /// guarantee as the account channel: page script cannot post on it.
 725     fn register_vi_channel(&self) {
 726         unsafe {
 727             let name = cstr(crate::vi::CHANNEL);
 728             let world = cstr(crate::vi::WORLD);
 729             if webkit_user_content_manager_register_script_message_handler(
 730                 self.ucm,
 731                 name.as_ptr(),
 732                 world.as_ptr(),
 733             ) == 0
 734             {
 735                 log::warn!("could not register the vi message channel");
 736                 return;
 737             }
 738             let signal = cstr(&format!("script-message-received::{}", crate::vi::CHANNEL));
 739             g_signal_connect_data(
 740                 self.ucm as *mut _,
 741                 signal.as_ptr(),
 742                 Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
 743                     on_vi_message as *const () as usize,
 744                 )),
 745                 Rc::into_raw(self.prompts.clone()) as gpointer,
 746                 Some(drop_prompts_ref),
 747                 0,
 748             );
 749         }
 750     }
 751 
 752     /// Install or remove the vi focus watcher. Off, pages carry nothing.
 753     pub fn set_vi_enabled(&mut self, on: bool) {
 754         unsafe {
 755             match (on, self.vi_watcher.take()) {
 756                 (true, None) => {
 757                     let source = cstr(&crate::vi::focus_watch_js());
 758                     let world = cstr(crate::vi::WORLD);
 759                     // Every frame: the field being clicked into is often in one.
 760                     let script = webkit_user_script_new_for_world(
 761                         source.as_ptr(),
 762                         WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
 763                         WebKitUserScriptInjectionTime::WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
 764                         world.as_ptr(),
 765                         std::ptr::null(),
 766                         std::ptr::null(),
 767                     );
 768                     webkit_user_content_manager_add_script(self.ucm, script);
 769                     self.vi_watcher = Some(script);
 770                 }
 771                 (false, Some(script)) => {
 772                     webkit_user_content_manager_remove_script(self.ucm, script);
 773                     webkit_user_script_unref(script);
 774                 }
 775                 (true, Some(script)) => self.vi_watcher = Some(script),
 776                 (false, None) => {}
 777             }
 778         }
 779         self.prompts.borrow_mut().vi_focus = None;
 780     }
 781 
 782     /// Whether the focused element takes text, if focus moved since last asked.
 783     pub fn take_vi_focus(&self) -> Option<bool> {
 784         self.prompts.borrow_mut().vi_focus.take()
 785     }
 786 
 787     /// The page text field open for typing in the active tab: its caret in
 788     /// window logical px, or the whole page until WebKit has placed the
 789     /// caret. What `display_list` claims, so a tap on a page field raises
 790     /// the on-screen keyboard.
 791     ///
 792     /// Not gated on `window_focused`: the toolkit enables text input only
 793     /// while the compositor has given this surface the text-input focus,
 794     /// which already says the same thing — and `window_focused` follows
 795     /// `wl_keyboard`, which a seat with no keyboard device never enters.
 796     pub fn page_text_field(&self) -> Option<(f32, f32, f32, f32)> {
 797         let tab = self.tabs.get(self.active)?;
 798         let field = super::ime::field(tab.view)?;
 799         let (w, h) = self.logical_size();
 800         let (x, y, cw, ch) = field.unwrap_or((0, 0, w, h));
 801         Some((x as f32, y as f32, cw.max(1) as f32, ch.max(1) as f32))
 802     }
 803 
 804     /// Whether a page field opened, closed or moved since the last call:
 805     /// the frame that claims it has to be built.
 806     pub fn take_page_text_field_changed(&self) -> bool {
 807         super::ime::take_changed()
 808     }
 809 
 810     /// Run `script` in the active tab's top frame, in the vi world, and queue
 811     /// its result as a string under `tag` for [`Self::take_vi_result`]. A
 812     /// failed script answers with an empty string, so a caller waiting on
 813     /// it is never left waiting.
 814     pub fn vi_eval(&self, script: &str, tag: u32) {
 815         let Some(t) = self.tabs.get(self.active) else { return };
 816         let ctx = Box::new((self.prompts.clone(), tag));
 817         unsafe {
 818             let (script, world) = (cstr(script), cstr(crate::vi::WORLD));
 819             webkit_web_view_evaluate_javascript(
 820                 t.webview,
 821                 script.as_ptr(),
 822                 -1,
 823                 world.as_ptr(),
 824                 std::ptr::null(),
 825                 std::ptr::null_mut(),
 826                 Some(on_vi_eval),
 827                 Box::into_raw(ctx) as gpointer,
 828             );
 829         }
 830     }
 831 
 832     pub fn take_vi_result(&self) -> Option<(u32, String)> {
 833         self.prompts.borrow_mut().vi_results.pop_front()
 834     }
 835 
 836     fn find_controller(&self) -> Option<*mut WebKitFindController> {
 837         let t = self.tabs.get(self.active)?;
 838         Some(unsafe { webkit_web_view_get_find_controller(t.webview) })
 839     }
 840 
 841     /// Find `text` in the active page, highlighting every match and
 842     /// scrolling to the first. Smart case, as qutebrowser does it: case
 843     /// matters only when the text has a capital in it.
 844     pub fn find(&self, text: &str, backwards: bool) {
 845         let Some(fc) = self.find_controller() else { return };
 846         use WebKitFindOptions::*;
 847         let mut opts = WEBKIT_FIND_OPTIONS_WRAP_AROUND;
 848         if !text.chars().any(char::is_uppercase) {
 849             opts |= WEBKIT_FIND_OPTIONS_CASE_INSENSITIVE;
 850         }
 851         if backwards {
 852             opts |= WEBKIT_FIND_OPTIONS_BACKWARDS;
 853         }
 854         let c = unsafe { cstr(text) };
 855         unsafe { webkit_find_controller_search(fc, c.as_ptr(), opts, 1000) };
 856     }
 857 
 858     /// The next match, in the search's own direction.
 859     pub fn find_next(&self) {
 860         if let Some(fc) = self.find_controller() {
 861             unsafe { webkit_find_controller_search_next(fc) }
 862         }
 863     }
 864 
 865     pub fn find_prev(&self) {
 866         if let Some(fc) = self.find_controller() {
 867             unsafe { webkit_find_controller_search_previous(fc) }
 868         }
 869     }
 870 
 871     /// Drop the search and its highlights.
 872     pub fn find_finish(&self) {
 873         if let Some(fc) = self.find_controller() {
 874             unsafe { webkit_find_controller_search_finish(fc) }
 875         }
 876         self.prompts.borrow_mut().find_result = None;
 877     }
 878 
 879     /// How the last search went: the match count, 0 for none.
 880     pub fn take_find_result(&self) -> Option<u32> {
 881         self.prompts.borrow_mut().find_result.take()
 882     }
 883 
 884     fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
 885         unsafe {
 886             let (p_display, p_ucm, p_session) = (
 887                 cstr("display"),
 888                 cstr("user-content-manager"),
 889                 cstr("network-session"),
 890             );
 891             let wv = g_object_new(
 892                 webkit_web_view_get_type(),
 893                 p_display.as_ptr(),
 894                 self.display,
 895                 p_ucm.as_ptr(),
 896                 self.ucm,
 897                 p_session.as_ptr(),
 898                 self.session,
 899                 std::ptr::null::<c_char>(),
 900             ) as *mut WebKitWebView;
 901             set_features(wv);
 902             let view = webkit_web_view_get_wpe_view(wv);
 903             wpe_view_set_toplevel(view, self.toplevel);
 904             // Signals, not polling: a background tab has to be able to report
 905             // its title without anyone asking the active webview.
 906             for sig in ["notify::title", "notify::uri", "notify::is-loading"] {
 907                 connect_notify(wv, sig, state);
 908             }
 909             // A dead or hung WebProcess. The first gets an error page in
 910             // `pump`; the second is offered to the chrome to ask about.
 911             connect_state(wv, "web-process-terminated", on_terminated as *const () as usize, state);
 912             connect_state(
 913                 wv,
 914                 "notify::is-web-process-responsive",
 915                 on_responsive as *const () as usize,
 916                 state,
 917             );
 918             // A page's alert/confirm/prompt, and HTTP auth challenges. Both
 919             // are held open and answered later, so the chrome can draw a real
 920             // dialog rather than the handler having to decide inline.
 921             connect_raw(
 922                 wv,
 923                 "script-dialog",
 924                 on_script_dialog as *const () as usize,
 925                 &self.prompts,
 926             );
 927             connect_raw(
 928                 wv,
 929                 "authenticate",
 930                 on_authenticate as *const () as usize,
 931                 &self.prompts,
 932             );
 933             // Right-click reaches the page as button 3; if the page does not
 934             // preventDefault, WebKit asks for a menu here. Returning TRUE
 935             // claims presentation, so the chrome draws it.
 936             connect_raw(
 937                 wv,
 938                 "context-menu",
 939                 on_context_menu as *const () as usize,
 940                 &self.prompts,
 941             );
 942             // A `<select>` asking to open. WPE draws no popup of its own: a
 943             // select nobody answers here simply never opens.
 944             connect_raw(
 945                 wv,
 946                 "show-option-menu",
 947                 on_show_option_menu as *const () as usize,
 948                 &self.prompts,
 949             );
 950             // A middle-clicked link is a background tab, not a navigation.
 951             connect_raw(
 952                 wv,
 953                 "decide-policy",
 954                 on_decide_policy as *const () as usize,
 955                 &self.prompts,
 956             );
 957             // Find-in-page answers on the controller, not the view.
 958             let fc = webkit_web_view_get_find_controller(wv);
 959             for (signal, cb) in [
 960                 ("found-text", on_found_text as *const () as usize),
 961                 ("failed-to-find-text", on_failed_to_find as *const () as usize),
 962             ] {
 963                 let name = cstr(signal);
 964                 g_signal_connect_data(
 965                     fc as *mut _,
 966                     name.as_ptr(),
 967                     Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
 968                     Rc::into_raw(self.prompts.clone()) as gpointer,
 969                     Some(drop_prompts_ref),
 970                     0,
 971                 );
 972             }
 973             let (lw, lh) = self.logical_size();
 974             wpe_view_resized(view, lw, lh);
 975             wpe_view_set_visible(view, 1);
 976             wpe_view_map(view);
 977             let curl = cstr(url.as_str());
 978             webkit_web_view_load_uri(wv, curl.as_ptr());
 979             (wv, view)
 980         }
 981     }
 982 
 983     /// Build the hidden spare webview so its WebProcess is up before the
 984     /// next `open_tab` needs it.
 985     fn prewarm_spare(&mut self) {
 986         if self.spare.is_some() {
 987             return;
 988         }
 989         let state = Rc::new(TabState::default());
 990         let url = Url::parse("about:blank").expect("about:blank");
 991         let (wv, view) = self.build_webview(&url, &state);
 992         unsafe {
 993             wpe_view_unmap(view);
 994             wpe_view_set_visible(view, 0);
 995         }
 996         self.spare = Some((wv, view, state));
 997     }
 998 
 999     pub fn open_tab(&mut self, url: Url) {
1000         self.add_tab(url, true);
1001     }
1002 
1003     /// Open `url` in a new tab behind the active one: it loads, and shows
1004     /// up in the strip, but the page on screen and its focus stay put.
1005     pub fn open_background_tab(&mut self, url: Url) {
1006         self.add_tab(url, false);
1007     }
1008 
1009     fn add_tab(&mut self, url: Url, show: bool) {
1010         let (webview, view, state) = match self.spare.take() {
1011             // Adopt the prewarmed webview; only the navigation is paid.
1012             Some((wv, view, state)) => {
1013                 unsafe {
1014                     let curl = cstr(url.as_str());
1015                     webkit_web_view_load_uri(wv, curl.as_ptr());
1016                 }
1017                 (wv, view, state)
1018             }
1019             None => {
1020                 let state = Rc::new(TabState::default());
1021                 let (wv, view) = self.build_webview(&url, &state);
1022                 if !show {
1023                     // Built mapped, like every webview; a background one
1024                     // starts the way a switched-away tab is left.
1025                     unsafe {
1026                         wpe_view_unmap(view);
1027                         wpe_view_set_visible(view, 0);
1028                     }
1029                 }
1030                 (wv, view, state)
1031             }
1032         };
1033         state.loading.set(true);
1034         *state.url.borrow_mut() = Some(url.clone());
1035         self.tabs.push(Tab {
1036             webview,
1037             view,
1038             state,
1039             title: None,
1040             url: Some(url),
1041             loading: true,
1042             image: None,
1043             mirror: None,
1044         });
1045         if show {
1046             self.activate(self.tabs.len() - 1);
1047         }
1048         // Replace the spare right away, but after the load started, so the
1049         // page fetch runs while this builds — measured, it does not show up
1050         // in the click-to-tab time.
1051         self.prewarm_spare();
1052     }
1053 
1054     /// Close a tab. Returns false when that was the last one (the app should
1055     /// exit; the tab is gone either way). Mirrors `ServoHost::close_tab`,
1056     /// including how the next active index is chosen.
1057     pub fn close_tab(&mut self, index: usize) -> bool {
1058         if index >= self.tabs.len() {
1059             return true;
1060         }
1061         let was_active = index == self.active;
1062         let old_active = self.active;
1063         self.close_option_menu();
1064         // Anything still held belongs to a view that may be the one about to
1065         // be destroyed; hand it back while it is still safe to. Losing that
1066         // frame costs a repaint, which the tab change causes anyway.
1067         self.release_held();
1068         // Dropping the Tab unrefs the webview and frees its registry image.
1069         drop(self.tabs.remove(index));
1070         if self.tabs.is_empty() {
1071             return false;
1072         }
1073         let next = if was_active {
1074             index.min(self.tabs.len() - 1)
1075         } else if old_active > index {
1076             old_active - 1
1077         } else {
1078             old_active
1079         };
1080         self.active = usize::MAX; // force activate() to do the work
1081         self.activate(next);
1082         true
1083     }
1084 
1085     /// Give back an unread buffer, if one is being held.
1086     fn release_held(&self) {
1087         if let Some((view, buffer)) = self.pending.borrow_mut().held.take() {
1088             unsafe {
1089                 wpe_view_buffer_rendered(view, buffer);
1090                 wpe_view_buffer_released(view, buffer);
1091             }
1092         }
1093     }
1094 
1095     /// Make tab `index` visible and focused. Mirrors `ServoHost::activate`,
1096     /// including the `usize::MAX` sentinel so the first call is not a no-op.
1097     pub fn activate(&mut self, index: usize) {
1098         if index >= self.tabs.len() || index == self.active {
1099             return;
1100         }
1101         unsafe {
1102             if let Some(old) = self.tabs.get(self.active) {
1103                 if self.window_focused {
1104                     wpe_view_focus_out(old.view);
1105                 }
1106                 wpe_view_unmap(old.view);
1107                 wpe_view_set_visible(old.view, 0);
1108             }
1109             self.active = index;
1110             // A select's list belongs to the page going away.
1111             self.close_option_menu();
1112             // Login fields reported by, and fill asks from, the tab going
1113             // away: a list must not open over the next one, and a pick made
1114             // there must have nowhere to land.
1115             self.clear_form_events();
1116             self.drop_fill_asks();
1117             let tab = &self.tabs[index];
1118             wpe_view_set_toplevel(tab.view, self.toplevel);
1119             wpe_view_set_visible(tab.view, 1);
1120             wpe_view_map(tab.view);
1121             let (lw, lh) = self.logical_size();
1122             wpe_view_resized(tab.view, lw, lh);
1123             if self.window_focused {
1124                 wpe_view_focus_in(tab.view);
1125             }
1126         }
1127     }
1128 
1129     pub fn tab_count(&self) -> usize {
1130         self.tabs.len()
1131     }
1132     pub fn active_index(&self) -> usize {
1133         self.active
1134     }
1135     pub fn tab(&self, index: usize) -> Option<&Tab> {
1136         self.tabs.get(index)
1137     }
1138     fn active_tab(&self) -> &Tab {
1139         &self.tabs[self.active]
1140     }
1141 
1142     /// The epoll fd carrying GLib's pollfd set, for `register_sources`.
1143     /// `None` if the bridge could not be created, in which case the app must
1144     /// fall back to calling [`Self::pump`] on a timer.
1145     pub fn poll_fd(&self) -> Option<std::os::fd::BorrowedFd<'_>> {
1146         self.poll.as_ref().map(|p| p.fd())
1147     }
1148 
1149     /// An owned duplicate of [`Self::poll_fd`], for handing to calloop.
1150     ///
1151     /// calloop wants to own what it polls, and the borrow above is tied to
1152     /// `&self`. A dup refers to the same epoll instance, so registrations
1153     /// made through the original are still what this observes.
1154     pub fn poll_fd_owned(&self) -> Option<std::os::fd::OwnedFd> {
1155         let fd = self.poll.as_ref()?.fd();
1156         rustix::io::dup(fd).ok()
1157     }
1158 
1159     /// When GLib next needs a pump that no fd will announce, as of the last
1160     /// pump (`GlibPoll::deadline`); `None` when nothing is scheduled.
1161     pub fn glib_deadline(&self) -> Option<std::time::Instant> {
1162         self.poll.as_ref().and_then(|p| p.deadline)
1163     }
1164 
1165     /// Whether pumps must keep coming even with GLib quiet: no GLib poll to
1166     /// watch at all, or the deadlock watch timing a hung tab (it only
1167     /// advances when pumped, and a hung tab is exactly the one producing no
1168     /// GLib activity).
1169     pub fn wants_heartbeat(&self) -> bool {
1170         self.poll.is_none() || self.deadlock_watch.is_some()
1171     }
1172 
1173     /// Drain GLib's pending work, then upload any frame it produced.
1174     /// Returns (new frame, any state change) like `ServoHost::pump`.
1175     pub fn pump(&mut self) -> (bool, bool) {
1176         // Clear the inner epoll first: calloop is level-triggered on that fd,
1177         // so leaving it readable across a pump that does not consume the
1178         // underlying socket would spin the loop.
1179         if let Some(p) = &self.poll {
1180             p.drain();
1181         }
1182         // `cce://cookies/clear` runs on WebKit's fetch path and cannot reach
1183         // the session from there, so it sets the flag and this acts on it —
1184         // the same relay `ServoHost::pump` uses. Timespan 0 clears them all.
1185         if self
1186             .clear_cookies
1187             .swap(false, std::sync::atomic::Ordering::SeqCst)
1188         {
1189             unsafe {
1190                 webkit_website_data_manager_clear(
1191                     webkit_network_session_get_website_data_manager(self.session),
1192                     WebKitWebsiteDataTypes::WEBKIT_WEBSITE_DATA_COOKIES,
1193                     0,
1194                     std::ptr::null_mut(),
1195                     None,
1196                     std::ptr::null_mut(),
1197                 );
1198             }
1199         }
1200         unsafe {
1201             while g_main_context_iteration(std::ptr::null_mut(), 0) != 0 {}
1202         }
1203         // WebKit opens and drops sockets as it loads, so the set that matters
1204         // is the one *after* dispatch, not before.
1205         if let Some(p) = &mut self.poll {
1206             p.sync();
1207         }
1208         self.watch_deadlock();
1209         // Nothing has drawn the last frame yet, so reading another would be
1210         // copying over a picture that was never shown. Leave the buffer held
1211         // until the draw. (Its view is waiting on `rendered` meanwhile, so it
1212         // is not followed by another; one from a different view supersedes
1213         // it and hands it back unread.)
1214         if self.pending_draw.get() {
1215             return (false, self.sync_page_state());
1216         }
1217         // One readback per pump, of the newest buffer only: everything the
1218         // engine rendered in between was handed back unread.
1219         let held = self.pending.borrow_mut().held.take();
1220         let dirty = self.sync_page_state();
1221         let Some((view, buffer)) = held else {
1222             return (false, dirty);
1223         };
1224         let damage = self.pending.borrow_mut().damage.remove(&(view as usize));
1225         // The frame belongs to the tab that drew it, which is not always the
1226         // one on screen. A view no tab owns is the prewarmed spare, or a tab
1227         // closed since: nothing shows it.
1228         let Some(index) = self.tabs.iter().position(|t| t.view == view) else {
1229             unsafe {
1230                 wpe_view_buffer_rendered(view, buffer);
1231                 wpe_view_buffer_released(view, buffer);
1232             }
1233             return (false, dirty);
1234         };
1235         let read = unsafe {
1236             let read = self.read_frame(index, buffer, damage);
1237             // Read now and drawn at the next frame: as good as on screen, so
1238             // the engine may start on the next one while this one waits for
1239             // the draw. That next one is then held, unacknowledged, until
1240             // the draw has happened — which is what keeps the engine to the
1241             // chrome's rate. (Said at the draw instead, the two never
1242             // overlapped, and a Muji banner animating at 60 fps in a visible
1243             // window dropped to 30.)
1244             wpe_view_buffer_rendered(view, buffer);
1245             // The pixels are ours now; the memory can go back.
1246             wpe_view_buffer_released(view, buffer);
1247             read
1248         };
1249         if frame_debug() {
1250             let mut p = self.pending.borrow_mut();
1251             p.counts.read += 1;
1252             if let Some((bytes, partial)) = read {
1253                 p.counts.bytes += bytes as u64;
1254                 p.counts.partial += partial as u64;
1255             }
1256             let now = std::time::Instant::now();
1257             let due = p.reported.is_none_or(|t| now.duration_since(t).as_secs_f32() >= 1.0);
1258             if due {
1259                 p.reported = Some(now);
1260                 let c = std::mem::take(&mut p.counts);
1261                 log::info!(
1262                     "frames: engine produced {}, read back {} ({} handed back unread), \
1263                      {} of them only their damage; {:.1} MB copied",
1264                     c.produced,
1265                     c.read,
1266                     c.produced.saturating_sub(c.read),
1267                     c.partial,
1268                     c.bytes as f64 / 1e6
1269                 );
1270             }
1271         }
1272         if read.is_none() || index != self.active {
1273             return (false, true);
1274         }
1275         self.pending_draw.set(true);
1276         (true, true)
1277     }
1278 
1279     /// Copy a finished frame into tab `index`'s image: only the damaged
1280     /// regions when the image already holds the frame before them, the
1281     /// whole frame otherwise. Returns the bytes copied and whether it was
1282     /// regions, or `None` for a buffer that could not be read.
1283     unsafe fn read_frame(
1284         &mut self,
1285         index: usize,
1286         buffer: *mut WPEBuffer,
1287         damage: Option<super::damage::Damage>,
1288     ) -> Option<(usize, bool)> {
1289         let shm = ShmFrame::of(buffer)?;
1290         let (w, h) = (shm.width, shm.height);
1291         // The one pixel anything actually reads back (see `sample_pixel`),
1292         // kept instead of a copy of the whole frame. Cloning 35 MB per frame
1293         // to serve a three-byte question cost 7 ms of every frame.
1294         let p0 = std::slice::from_raw_parts(shm.data, 4);
1295         self.last_pixel = Some((p0[2], p0[1], p0[0]));
1296         let tab = &mut self.tabs[index];
1297         // Regions only make sense against the picture they change: this
1298         // tab's image, at this size. No damage at all means nothing changed.
1299         let current = tab.image.is_some_and(|(_, iw, ih)| (iw, ih) == (w, h));
1300         let regions = match damage {
1301             _ if full_frames() || !current => None,
1302             None => Some(Vec::new()),
1303             Some(d) => d.regions(w, h),
1304         };
1305         if let (Some(regions), Some((id, ..))) = (regions, tab.image) {
1306             let len = super::damage::packed_len(&regions);
1307             let mut px = cce_ui::vk::recycle_buffer(len);
1308             super::damage::pack(shm.data, shm.stride, &regions, &mut px);
1309             if let Some(mirror) = tab.mirror.as_mut() {
1310                 check_regions(mirror, &shm, &px, &regions, index);
1311             }
1312             cce_ui::vk::update_pixel_regions(id, px, w, h, cce_ui::vk::PixelFormat::Bgra, regions);
1313             return Some((len, true));
1314         }
1315         let px = shm.copy_all();
1316         let len = px.len();
1317         if damage_check() {
1318             tab.mirror = Some(px.clone());
1319         }
1320         match tab.image {
1321             // Same tab, same size: replace the contents of the image that is
1322             // already there. No allocation, no descriptor, and above all no
1323             // image freed — freeing one waits for the whole device to go idle,
1324             // which on this path meant once per frame.
1325             Some((id, iw, ih)) if (iw, ih) == (w, h) => {
1326                 cce_ui::vk::update_pixels(id, px, w, h, cce_ui::vk::PixelFormat::Bgra);
1327             }
1328             _ => {
1329                 let id = cce_ui::vk::upload_pixels(px, w, h, cce_ui::vk::PixelFormat::Bgra);
1330                 if let Some((old, ..)) = tab.image.replace((id, w, h)) {
1331                     cce_ui::vk::free_image(old);
1332                 }
1333             }
1334         }
1335         Some((len, false))
1336     }
1337 
1338     /// Re-paint the page into a renderer that has just replaced the one the
1339     /// tab images were uploaded to.
1340     ///
1341     /// An image id belongs to a **renderer**, not to the process: `cce-ui`'s
1342     /// `window_runner` repairs a lost Wayland transport by opening a new
1343     /// session around the same `Application`, which rebuilds the renderer and
1344     /// with it the image table. A draw for an unknown id is skipped rather
1345     /// than reported, so the chrome came back over an empty page.
1346     ///
1347     /// Two halves. Dropping the ids is the easy one. The hard one is that
1348     /// nothing would otherwise provoke a new frame: a page that has finished
1349     /// loading renders once and then only on damage, so `pump` would find no
1350     /// buffer held and the window would sit blank until the user scrolled or
1351     /// navigated. Remapping the active view is the nudge — it is what
1352     /// `activate` already relies on to get a frame out of a tab being
1353     /// switched to.
1354     ///
1355     /// A buffer still held from the old session is deliberately kept: its
1356     /// pixels are fine, and the next `pump` uploads them under a fresh id.
1357     pub fn renderer_replaced(&mut self) {
1358         for tab in &mut self.tabs {
1359             if let Some((id, ..)) = tab.image.take() {
1360                 // A free for an id the new renderer never had is a no-op, and
1361                 // ids are process-unique, so this cannot reach a live image.
1362                 cce_ui::vk::free_image(id);
1363             }
1364         }
1365         unsafe {
1366             let view = self.active_tab().view;
1367             wpe_view_unmap(view);
1368             wpe_view_set_visible(view, 1);
1369             wpe_view_map(view);
1370             let (lw, lh) = self.logical_size();
1371             wpe_view_resized(view, lw, lh);
1372         }
1373     }
1374 
1375     /// The chrome drew: whatever was uploaded is on screen, so the next
1376     /// engine frame is worth reading. Called from `display_list`.
1377     ///
1378     /// Returns whether a frame is already waiting to be read. Its view is
1379     /// held up until it is (`pump` says `rendered` as it reads), and having
1380     /// been held up it makes no noise that would turn the loop — so the
1381     /// caller must wake a pump, or the page sits on that frame until GLib's
1382     /// next timeout.
1383     ///
1384     /// This is what paces the engine to the chrome: one frame being drawn,
1385     /// one more finished and waiting, and nothing composited beyond that. So
1386     /// the page runs at the output's refresh while the window is up, at the
1387     /// runner's starvation fallback (~4 a second) with the display off, and
1388     /// not at all where nothing draws. Anything that reads frames without a
1389     /// chrome (the examples) must call this too, or the page stops after its
1390     /// second frame.
1391     pub fn frame_drawn(&self) -> bool {
1392         self.pending_draw.set(false);
1393         self.pending.borrow().held.is_some()
1394     }
1395 
1396     /// Fold each tab's signal-written state into the fields the chrome reads.
1397     ///
1398     /// Every tab, not just the active one — that is the whole point of moving
1399     /// off polling. The tab strip shows a title per tab, so a background tab
1400     /// finishing a load has to be visible without switching to it.
1401     fn sync_page_state(&mut self) -> bool {
1402         let mut changed = false;
1403         for tab in &mut self.tabs {
1404             if !tab.state.dirty.replace(false) {
1405                 continue;
1406             }
1407             if let Some(reason) = tab.state.terminated.take() {
1408                 // Stopped as a deadlock: just load the page again. A plain
1409                 // load, not a reload, so a page that came from a form post
1410                 // is not posted twice.
1411                 let reload = tab.state.auto_reload.take().then_some(tab.url.as_ref()).flatten();
1412                 if let Some(u) = reload {
1413                     log::warn!("reloading {u} after stopping its deadlocked web process");
1414                     unsafe {
1415                         let c = cstr(u.as_str());
1416                         webkit_web_view_load_uri(tab.webview, c.as_ptr());
1417                     }
1418                 } else {
1419                     // Loading anything respawns a WebProcess; this loads the
1420                     // error page under the dead page's URL. Reload — the chrome's
1421                     // or the page's link — then fetches the real one.
1422                     log::warn!(
1423                         "web process for {} terminated (reason {reason})",
1424                         tab.url.as_ref().map_or("<no url>", |u| u.as_str())
1425                     );
1426                     unsafe {
1427                         let html = cstr(&terminated_page(tab.url.as_ref(), reason));
1428                         let uri = tab.url.as_ref().map(|u| cstr(u.as_str()));
1429                         webkit_web_view_load_alternate_html(
1430                             tab.webview,
1431                             html.as_ptr(),
1432                             uri.as_ref().map_or(std::ptr::null(), |u| u.as_ptr()),
1433                             // The page's own URL as the base too: without one the
1434                             // error page is `about:blank` to itself, so its
1435                             // Reload link — refused outright when the dead page
1436                             // was a `file:` — had nowhere real to go.
1437                             uri.as_ref().map_or(std::ptr::null(), |u| u.as_ptr()),
1438                         );
1439                     }
1440                 }
1441             }
1442             tab.title = tab.state.title.borrow().clone();
1443             if let Some(u) = tab.state.url.borrow().clone() {
1444                 tab.url = Some(u);
1445             }
1446             tab.loading = tab.state.loading.get();
1447             changed = true;
1448         }
1449         changed
1450     }
1451 
1452     /// Top-left pixel of the last frame, for tests that need to assert on
1453     /// what was actually rendered rather than on what was configured.
1454     pub fn sample_pixel(&self) -> Option<(u8, u8, u8)> {
1455         self.last_pixel
1456     }
1457 
1458     pub fn image(&self) -> Option<(u32, u32, u32)> {
1459         self.active_tab().image
1460     }
1461     pub fn title(&self) -> Option<String> {
1462         self.active_tab().title.clone()
1463     }
1464     pub fn url(&self) -> Option<Url> {
1465         self.active_tab().url.clone()
1466     }
1467     pub fn loading(&self) -> bool {
1468         self.active_tab().loading
1469     }
1470 
1471     /// The active tab's WebProcess has stopped answering, and the person has
1472     /// not already chosen to wait on it.
1473     pub fn active_unresponsive(&self) -> bool {
1474         self.tabs.get(self.active).is_some_and(|t| self.hung(t) && !t.state.hang_waived.get())
1475     }
1476 
1477     /// The tab's process has stopped answering — by WebKit's timer or an
1478     /// overdue ping. Never while a page dialog or auth challenge is up: the
1479     /// process is blocked on *us* then, and a ping sent just before it
1480     /// opened goes unanswered for as long as it stays open.
1481     fn hung(&self, t: &Tab) -> bool {
1482         let p = self.prompts.borrow();
1483         if p.dialog.is_some() || p.auth.is_some() {
1484             return false;
1485         }
1486         let ping_overdue = t.state.ping_since.get().is_some_and(|at| at.elapsed() >= HANG_GRACE);
1487         t.state.unresponsive.get() || ping_overdue
1488     }
1489 
1490     /// Recover a deadlocked page without asking.
1491     ///
1492     /// A deadlock and a busy page look alike from here — both stop
1493     /// answering — but a deadlocked process burns no CPU (the 2026-10-05
1494     /// one sat at zero, every thread parked on a lock) and a spinning script
1495     /// burns a whole core. So while the active tab is hung, every page
1496     /// process's CPU time is sampled; if none of them has used more than a
1497     /// sliver of it across `DEADLOCK_WATCH`, the process is stopped and the
1498     /// page loaded again. Anything busier is left to the prompt, as is a
1499     /// hang the person chose to wait on, and a tab recovered within
1500     /// `AUTO_RECOVER_GAP`.
1501     fn watch_deadlock(&mut self) {
1502         let state = self
1503             .tabs
1504             .get(self.active)
1505             .filter(|t| self.hung(t) && !t.state.hang_waived.get())
1506             .map(|t| t.state.clone());
1507         let Some(state) = state else {
1508             self.deadlock_watch = None;
1509             return;
1510         };
1511         if state.last_auto.get().is_some_and(|at| at.elapsed() < AUTO_RECOVER_GAP) {
1512             return;
1513         }
1514         let watching = self.deadlock_watch.as_ref().filter(|w| Rc::ptr_eq(&w.tab, &state));
1515         let Some(watch) = watching else {
1516             self.deadlock_watch = Some(DeadlockWatch {
1517                 tab: state,
1518                 since: std::time::Instant::now(),
1519                 ticks: web_process_ticks(),
1520             });
1521             return;
1522         };
1523         let elapsed = watch.since.elapsed();
1524         if elapsed < DEADLOCK_WATCH {
1525             return;
1526         }
1527         let now = web_process_ticks();
1528         // 5% of one core, at /proc's 100 ticks a second. A process that
1529         // appeared mid-watch is measured from zero, which counts its whole
1530         // startup against it — on the side of not stopping anything.
1531         let budget = (elapsed.as_secs_f64() * 100.0 * 0.05) as u64;
1532         let idle = !now.is_empty()
1533             && now.iter().all(|(pid, t)| {
1534                 t.saturating_sub(watch.ticks.get(pid).copied().unwrap_or(0)) <= budget
1535             });
1536         if !idle {
1537             // Busy: a script, most likely. Watch again from here, so a page
1538             // that stops spinning and then deadlocks is still caught.
1539             self.deadlock_watch = Some(DeadlockWatch {
1540                 tab: state,
1541                 since: std::time::Instant::now(),
1542                 ticks: now,
1543             });
1544             return;
1545         }
1546         self.deadlock_watch = None;
1547         log::warn!(
1548             "{} has not answered in {:.0}s and no page process is running; \
1549              stopping it as deadlocked",
1550             self.tabs[self.active].url.as_ref().map_or("<no url>", |u| u.as_str()),
1551             (elapsed + HANG_GRACE).as_secs_f64(),
1552         );
1553         state.auto_reload.set(true);
1554         state.last_auto.set(Some(std::time::Instant::now()));
1555         self.stop_unresponsive();
1556     }
1557 
1558     /// A dialog or auth challenge was answered: whatever ping was waiting
1559     /// behind it was waiting on the person, not on a hung page.
1560     fn forget_ping(&self) {
1561         if let Some(t) = self.tabs.get(self.active) {
1562             t.state.ping_since.set(None);
1563         }
1564     }
1565 
1566     /// Ask the active page's main thread for an answer, to learn whether it
1567     /// is still there.
1568     ///
1569     /// WebKit's own responsiveness timer misses the commonest hang: pointer
1570     /// events are queued behind an unacknowledged one, and a *move* — which
1571     /// always comes before a click — does not start the timer. So the
1572     /// clicks behind it are never even sent, and the page that ignores them
1573     /// is never reported. A no-op script, in a world the page cannot see,
1574     /// is answered by the same main thread, so its silence is the hang.
1575     fn ping(&self) {
1576         let Some(t) = self.tabs.get(self.active) else { return };
1577         if t.state.ping_since.get().is_some() {
1578             return;
1579         }
1580         t.state.ping_since.set(Some(std::time::Instant::now()));
1581         unsafe {
1582             let (script, world) = (cstr("0"), cstr(PING_WORLD));
1583             webkit_web_view_evaluate_javascript(
1584                 t.webview,
1585                 script.as_ptr(),
1586                 -1,
1587                 world.as_ptr(),
1588                 std::ptr::null(),
1589                 std::ptr::null_mut(),
1590                 Some(on_ping),
1591                 Rc::into_raw(t.state.clone()) as gpointer,
1592             );
1593             g_timeout_add_once(HANG_GRACE.as_millis() as u32 + 50, Some(on_ping_due), std::ptr::null_mut());
1594         }
1595     }
1596 
1597     /// Leave the active tab's hang alone until it recovers.
1598     pub fn wait_unresponsive(&self) {
1599         if let Some(t) = self.tabs.get(self.active) {
1600             t.state.hang_waived.set(true);
1601         }
1602     }
1603 
1604     /// Kill the active tab's hung WebProcess. The termination signal follows,
1605     /// and with it the error page offering a reload.
1606     pub fn stop_unresponsive(&self) {
1607         if let Some(t) = self.tabs.get(self.active) {
1608             unsafe { webkit_web_view_terminate_web_process(t.webview) }
1609         }
1610     }
1611 
1612     pub fn load(&self, url: Url) {
1613         unsafe {
1614             let c = cstr(url.as_str());
1615             webkit_web_view_load_uri(self.active_tab().webview, c.as_ptr());
1616         }
1617     }
1618     pub fn reload(&self) {
1619         unsafe { webkit_web_view_reload(self.active_tab().webview) }
1620     }
1621     pub fn back(&self) {
1622         unsafe { webkit_web_view_go_back(self.active_tab().webview) }
1623     }
1624     pub fn forward(&self) {
1625         unsafe { webkit_web_view_go_forward(self.active_tab().webview) }
1626     }
1627     pub fn can_go_back(&self) -> bool {
1628         unsafe { webkit_web_view_can_go_back(self.active_tab().webview) != 0 }
1629     }
1630     pub fn can_go_forward(&self) -> bool {
1631         unsafe { webkit_web_view_can_go_forward(self.active_tab().webview) != 0 }
1632     }
1633 
1634     // ---- settings and app-side state ----
1635     //
1636     // These exist so `WebKitHost` and `ServoHost` present the same surface;
1637     // bookmarks and history are app state either way, so they are identical.
1638 
1639     pub fn set_history_enabled(&mut self, on: bool) {
1640         self.history_enabled = on;
1641     }
1642 
1643     /// Install or remove the inverting user stylesheet.
1644     ///
1645     /// Simpler than the Servo path, which needed *two* timed reloads to let
1646     /// a user-content change and a scheme flip settle. WebKit applies user
1647     /// content to live pages, so a reload is enough — and only to re-run
1648     /// pages that already computed their colours.
1649     pub fn set_force_dark(&mut self, on: bool) {
1650         if on == self.force_dark {
1651             return;
1652         }
1653         self.force_dark = on;
1654         unsafe {
1655             if on {
1656                 let css = cstr(FORCE_DARK_CSS);
1657                 let sheet = webkit_user_style_sheet_new(
1658                     css.as_ptr(),
1659                     WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
1660                     WebKitUserStyleLevel::WEBKIT_USER_STYLE_LEVEL_USER,
1661                     std::ptr::null(),
1662                     std::ptr::null(),
1663                 );
1664                 webkit_user_content_manager_add_style_sheet(self.ucm, sheet);
1665                 webkit_user_style_sheet_unref(sheet);
1666             } else {
1667                 webkit_user_content_manager_remove_all_style_sheets(self.ucm);
1668             }
1669             for tab in &self.tabs {
1670                 webkit_web_view_reload(tab.webview);
1671             }
1672         }
1673     }
1674 
1675     /// What pages see for `prefers-color-scheme`, via WPE's own setting.
1676     pub fn set_color_scheme_dark(&self, dark: bool) {
1677         unsafe {
1678             let settings = wpe_display_get_settings(self.display);
1679             let key = cstr("/wpe-platform/dark-mode");
1680             let mut err: *mut GError = std::ptr::null_mut();
1681             wpe_settings_set_boolean(
1682                 settings,
1683                 key.as_ptr(),
1684                 dark as gboolean,
1685                 WPESettingsSource::WPE_SETTINGS_SOURCE_APPLICATION,
1686                 &mut err,
1687             );
1688         }
1689     }
1690 
1691     /// A navigation became a download since the last check.
1692     pub fn take_download_started(&self) -> bool {
1693         self.download_started.replace(false)
1694     }
1695 
1696     pub fn active_bookmarked(&self) -> bool {
1697         self.active_tab()
1698             .url
1699             .as_ref()
1700             .is_some_and(|u| self.bookmarks.contains(u.as_str()))
1701     }
1702 
1703     pub fn toggle_bookmark(&self) {
1704         let tab = self.active_tab();
1705         if let Some(url) = &tab.url {
1706             self.bookmarks
1707                 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
1708         }
1709     }
1710 
1711     /// The bookmarks store, shared with the `cce://bookmarks` page; the
1712     /// chrome's bookmarks menu lists and edits it directly.
1713     pub fn bookmarks(&self) -> std::sync::Arc<crate::pages::Bookmarks> {
1714         self.bookmarks.clone()
1715     }
1716 
1717     /// The favorites store, shared with the `cce://favorites` page; the
1718     /// chrome reads the strip from it.
1719     pub fn favorites(&self) -> std::sync::Arc<crate::pages::Favorites> {
1720         self.favorites.clone()
1721     }
1722 
1723     pub fn active_favorited(&self) -> bool {
1724         self.active_tab()
1725             .url
1726             .as_ref()
1727             .is_some_and(|u| self.favorites.contains(u.as_str()))
1728     }
1729 
1730     pub fn toggle_favorite(&self) {
1731         let tab = self.active_tab();
1732         if let Some(url) = &tab.url {
1733             self.favorites
1734                 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
1735         }
1736     }
1737 
1738     /// Clipboard on the page. WebKit takes these as named editing commands,
1739     /// so unlike the Servo backend there is no separate clipboard delegate to
1740     /// implement — it goes through the platform clipboard itself.
1741     /// Push the system selection into WPE. Separated so it can be done
1742     /// ahead of a paste rather than in the same breath — the web process is
1743     /// a different process, and the content has to reach it.
1744     pub fn sync_clipboard(&self) {
1745         unsafe { super::subclass::sync_system_clipboard(self.display) }
1746     }
1747 
1748     pub fn editing_action_cmd(&self, command: crate::EditingCommand) {
1749         unsafe {
1750             // WebKit will not read a clipboard it thinks is empty, so the
1751             // system selection has to be pushed in before Paste runs.
1752             if matches!(command, crate::EditingCommand::Paste) {
1753                 super::subclass::sync_system_clipboard(self.display);
1754             }
1755             let c = cstr(match command {
1756                 crate::EditingCommand::Copy => "Copy",
1757                 crate::EditingCommand::Cut => "Cut",
1758                 crate::EditingCommand::Paste => "Paste",
1759             });
1760             webkit_web_view_execute_editing_command(self.active_tab().webview, c.as_ptr());
1761         }
1762     }
1763 
1764     // ---- pending prompts ----
1765 
1766     /// The dialog a page is currently blocked on, if any. Cloned rather than
1767     /// taken: the chrome redraws from this every frame, and the page stays
1768     /// blocked until [`Self::respond_dialog`].
1769     pub fn pending_dialog(&self) -> Option<PendingDialog> {
1770         self.prompts.borrow().dialog.as_ref().map(|(_, d)| d.clone())
1771     }
1772 
1773     /// One-shot: the context menu the page just requested, if any. Taken
1774     /// rather than cloned — the chrome opens it once, at the pointer.
1775     pub fn take_context_menu(&self) -> Option<ContextMenuInfo> {
1776         self.prompts.borrow_mut().context_menu.take()
1777     }
1778 
1779     /// One-shot: the `<select>` list the page just asked to show, if any.
1780     /// The menu itself stays held until [`Self::pick_option`] or
1781     /// [`Self::close_option_menu`] answers it, or the page closes it.
1782     pub fn take_option_menu(&self) -> Option<OptionMenuInfo> {
1783         self.prompts.borrow_mut().option_menu_new.take()
1784     }
1785 
1786     /// Whether a select's list is still waiting on an answer. False once the
1787     /// page has closed it itself — the select was removed, the page
1788     /// navigated — which is how the chrome learns to take its list down.
1789     pub fn option_menu_open(&self) -> bool {
1790         self.prompts.borrow().option_menu.is_some()
1791     }
1792 
1793     /// Choose option `index` and close the list. The select changes value
1794     /// and fires its `input`/`change` as for any pick.
1795     pub fn pick_option(&self, index: usize) {
1796         let Some(menu) = self.prompts.borrow_mut().option_menu.take() else { return };
1797         unsafe {
1798             webkit_option_menu_activate_item(menu, index as u32);
1799             webkit_option_menu_close(menu);
1800             g_object_unref(menu as *mut _);
1801         }
1802     }
1803 
1804     /// Close the list without choosing. Nothing is selected on the way
1805     /// (`select_item` is never called), so closing leaves the value as it was.
1806     pub fn close_option_menu(&self) {
1807         let menu = {
1808             let mut p = self.prompts.borrow_mut();
1809             p.option_menu_new = None;
1810             p.option_menu.take()
1811         };
1812         // Taken out first: `close` emits the menu's own close signal, whose
1813         // handler borrows the prompts too.
1814         if let Some(menu) = menu {
1815             unsafe {
1816                 webkit_option_menu_close(menu);
1817                 g_object_unref(menu as *mut _);
1818             }
1819         }
1820     }
1821 
1822     /// Links the pages asked to open in background tabs since the last call.
1823     pub fn take_background_opens(&self) -> Vec<Url> {
1824         std::mem::take(&mut self.prompts.borrow_mut().background_opens)
1825     }
1826 
1827     /// Fetch `uri` through WebKit's download pipeline — same signals, same
1828     /// store, same `cce://downloads` page as a navigated download. This is
1829     /// what "Download Link/Image" in the context menu dispatches to.
1830     pub fn download_uri(&self, uri: &str) {
1831         unsafe {
1832             let c = cstr(uri);
1833             webkit_web_view_download_uri(self.active_tab().webview, c.as_ptr());
1834         }
1835     }
1836 
1837     pub fn pending_auth(&self) -> Option<PendingAuth> {
1838         self.prompts.borrow().auth.as_ref().map(|(_, a)| a.clone())
1839     }
1840 
1841     /// Answer the page. `text` carries a `prompt`'s reply; it is ignored for
1842     /// alert and confirm.
1843     pub fn respond_dialog(&self, ok: bool, text: Option<&str>) {
1844         let Some((dialog, pending)) = self.prompts.borrow_mut().dialog.take() else {
1845             return;
1846         };
1847         self.forget_ping();
1848         unsafe {
1849             if pending.prompt_default.is_some() {
1850                 // A cancelled prompt must return null, not "" — a page
1851                 // distinguishes the two.
1852                 if ok {
1853                     let t = cstr(text.unwrap_or(""));
1854                     webkit_script_dialog_prompt_set_text(dialog, t.as_ptr());
1855                 } else {
1856                     webkit_script_dialog_prompt_set_text(dialog, std::ptr::null());
1857                 }
1858             } else if pending.has_cancel {
1859                 webkit_script_dialog_confirm_set_confirmed(dialog, ok as gboolean);
1860             }
1861             webkit_script_dialog_close(dialog);
1862             webkit_script_dialog_unref(dialog);
1863         }
1864     }
1865 
1866     /// Answer an auth challenge, or cancel it. Credentials are used for this
1867     /// session only — `WEBKIT_CREDENTIAL_PERSISTENCE_FOR_SESSION` — rather
1868     /// than written to the profile, which would need a deliberate decision
1869     /// about storing passwords on disk.
1870     pub fn respond_auth(&self, credentials: Option<(&str, &str)>) {
1871         let Some((request, _)) = self.prompts.borrow_mut().auth.take() else {
1872             return;
1873         };
1874         self.forget_ping();
1875         unsafe {
1876             match credentials {
1877                 Some((user, password)) => {
1878                     let (u, p) = (cstr(user), cstr(password));
1879                     let cred = webkit_credential_new(
1880                         u.as_ptr(),
1881                         p.as_ptr(),
1882                         WebKitCredentialPersistence::WEBKIT_CREDENTIAL_PERSISTENCE_FOR_SESSION,
1883                     );
1884                     webkit_authentication_request_authenticate(request, cred);
1885                     webkit_credential_free(cred);
1886                 }
1887                 None => webkit_authentication_request_cancel(request),
1888             }
1889             g_object_unref(request as *mut _);
1890         }
1891     }
1892 
1893     // ---- input ----
1894     //
1895     // Coordinates are device pixels relative to the view origin, matching
1896     // `ServoHost`'s convention so `main.rs` scales them the same way. Events
1897     // are refcounted; `wpe_view_event` takes its own reference, so each one is
1898     // unreffed here after delivery.
1899 
1900     pub fn mouse_move(&self, x_px: f32, y_px: f32) {
1901         unsafe {
1902             let view = self.active_tab().view;
1903             let (x, y) = self.to_logical(x_px, y_px);
1904             let e = wpe_event_pointer_move_new(
1905                 WPEEventType::WPE_EVENT_POINTER_MOVE,
1906                 view,
1907                 WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
1908                 input::now_ms(),
1909                 self.held_buttons.get(),
1910                 x,
1911                 y,
1912                 0.0,
1913                 0.0,
1914             );
1915             self.send(view, e);
1916         }
1917     }
1918 
1919     pub fn mouse_button_ui(&self, button: MouseButton, pressed: bool, x_px: f32, y_px: f32) {
1920         let Some(n) = input::button_number(button) else {
1921             return;
1922         };
1923         if pressed {
1924             self.ping();
1925         }
1926         unsafe {
1927             let view = self.active_tab().view;
1928             let time = input::now_ms();
1929             // WPE tracks double/triple clicks for us; a frozen clock here
1930             // would make every click read as a repeat.
1931             let (x, y) = self.to_logical(x_px, y_px);
1932             let press_count = if pressed {
1933                 wpe_view_compute_press_count(view, x, y, n, time)
1934             } else {
1935                 0
1936             };
1937             // The mask describes the state *after* this event, which is
1938             // what a DOM `buttons` reads on mousedown and mouseup.
1939             let bit = input::button_modifier(n);
1940             let held = if pressed {
1941                 self.held_buttons.get() | bit
1942             } else {
1943                 self.held_buttons.get() & !bit
1944             };
1945             self.held_buttons.set(held);
1946             let e = wpe_event_pointer_button_new(
1947                 if pressed {
1948                     WPEEventType::WPE_EVENT_POINTER_DOWN
1949                 } else {
1950                     WPEEventType::WPE_EVENT_POINTER_UP
1951                 },
1952                 view,
1953                 WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
1954                 time,
1955                 held,
1956                 n,
1957                 x,
1958                 y,
1959                 press_count,
1960             );
1961             self.send(view, e);
1962         }
1963     }
1964 
1965     /// Wheel deltas in device pixels, in cce-ui's winit convention (positive
1966     /// = scroll up), passed through **unchanged**.
1967     ///
1968     /// Measured, not assumed: WPE already inverts on the way to the DOM, so a
1969     /// negation here double-inverts and the page scrolls backwards. An
1970     /// earlier cut negated these and `examples/wpe_input` caught it — the
1971     /// page reported `deltaY` of the wrong sign.
1972     pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
1973         // cce-ui publishes the gesture phase of the wheel event being
1974         // dispatched: a trackpad's finger lift arrives as a zero delta in
1975         // FingerEnd, which is WebKit's scroll-stop — the signal its own
1976         // kinetic scrolling keys off. Finger phases report the touchpad
1977         // source so the engine treats the deltas as a gesture, not clicks.
1978         let phase = cce_ui::widget::scroll_motion::current_scroll_phase();
1979         let (source, is_stop) = match phase {
1980             cce_ui::widget::ScrollPhase::Wheel => (WPEInputSource::WPE_INPUT_SOURCE_MOUSE, 0),
1981             cce_ui::widget::ScrollPhase::Finger => (WPEInputSource::WPE_INPUT_SOURCE_TOUCHPAD, 0),
1982             cce_ui::widget::ScrollPhase::FingerEnd => (WPEInputSource::WPE_INPUT_SOURCE_TOUCHPAD, 1),
1983         };
1984         unsafe {
1985             let view = self.active_tab().view;
1986             let (x, y) = self.to_logical(x_px, y_px);
1987             let e = wpe_event_scroll_new(
1988                 view,
1989                 source,
1990                 input::now_ms(),
1991                 0,
1992                 dx_px / self.scale as f64,
1993                 dy_px / self.scale as f64,
1994                 1, // precise deltas: these are pixels, not notches
1995                 is_stop,
1996                 x,
1997                 y,
1998             );
1999             self.send(view, e);
2000         }
2001     }
2002 
2003     /// Takes cce-ui's `KeyEvent` directly — the keysym mapping lives in
2004     /// `input`, so the chrome never learns engine vocabulary.
2005     pub fn key_ui(&self, event: &KeyEvent) {
2006         let Some(keyval) = input::keyval(&event.logical_key) else {
2007             return;
2008         };
2009         let pressed = input::is_pressed(event);
2010         unsafe {
2011             let view = self.active_tab().view;
2012             let e = wpe_event_keyboard_new(
2013                 if pressed {
2014                     WPEEventType::WPE_EVENT_KEYBOARD_KEY_DOWN
2015                 } else {
2016                     WPEEventType::WPE_EVENT_KEYBOARD_KEY_UP
2017                 },
2018                 view,
2019                 WPEInputSource::WPE_INPUT_SOURCE_KEYBOARD,
2020                 input::now_ms(),
2021                 input::modifiers(event.ctrl, event.shift, event.alt),
2022                 0, // hardware keycode: unknown to us, and WebKit works off keyval
2023                 keyval,
2024             );
2025             self.send(view, e);
2026         }
2027     }
2028 
2029     /// Window focus, from the runner's keyboard enter/leave.
2030     ///
2031     /// WebKit needs **two** things before it paints a text caret: the view
2032     /// focused and the toplevel `ACTIVE`. Keystrokes reach a focused field
2033     /// with neither, so the only symptom of missing this is a field you can
2034     /// type into with no caret in it — which shipped, unnoticed, because
2035     /// nothing called this at all. `document.hasFocus()` reads the same pair;
2036     /// `examples/wpe_focus.rs` checks it.
2037     pub fn focus(&mut self, focused: bool) {
2038         self.window_focused = focused;
2039         unsafe {
2040             let state = wpe_toplevel_get_state(self.toplevel);
2041             let state = if focused {
2042                 state | WPEToplevelState::WPE_TOPLEVEL_STATE_ACTIVE
2043             } else {
2044                 state & !WPEToplevelState::WPE_TOPLEVEL_STATE_ACTIVE
2045             };
2046             wpe_toplevel_state_changed(self.toplevel, state);
2047             if let Some(tab) = self.tabs.get(self.active) {
2048                 if focused {
2049                     wpe_view_focus_in(tab.view)
2050                 } else {
2051                     wpe_view_focus_out(tab.view)
2052                 }
2053             }
2054         }
2055     }
2056 
2057     unsafe fn send(&self, view: *mut WPEView, event: *mut WPEEvent) {
2058         if event.is_null() {
2059             return;
2060         }
2061         wpe_view_event(view, event);
2062         wpe_event_unref(event);
2063     }
2064 
2065     /// Resize, in **physical** pixels — `ServoHost`'s convention, so
2066     /// `main.rs` passes `content_px()` to either backend unchanged.
2067     ///
2068     /// WPE wants the opposite split: a **logical** size plus a scale, and it
2069     /// produces a buffer of `size * scale`. Handing it physical pixels while
2070     /// leaving the scale at 1 makes it lay out 2400x1600 *CSS* pixels on a 2x
2071     /// display — the viewport reads as twice as wide as it is and the whole
2072     /// page renders at half size. That is the bug this converts away.
2073     pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
2074         self.size_px = (width_px.max(1), height_px.max(1));
2075         self.scale = scale.max(0.01);
2076         let (lw, lh) = self.logical_size();
2077         unsafe {
2078             wpe_toplevel_scale_changed(self.toplevel, self.scale as f64);
2079             wpe_toplevel_resized(self.toplevel, lw, lh);
2080             let view = self.active_tab().view;
2081             wpe_view_resized(view, lw, lh);
2082         }
2083     }
2084 
2085     /// The view size WPE works in: physical divided back out by the scale.
2086     fn logical_size(&self) -> (i32, i32) {
2087         (
2088             ((self.size_px.0 as f32 / self.scale).round() as i32).max(1),
2089             ((self.size_px.1 as f32 / self.scale).round() as i32).max(1),
2090         )
2091     }
2092 
2093     /// Physical pointer coordinates into the view's logical space, for the
2094     /// same reason as `resize` — a click at the bottom of a 2x window would
2095     /// otherwise land twice as far down the page as the cursor.
2096     fn to_logical(&self, x_px: f32, y_px: f32) -> (f64, f64) {
2097         ((x_px / self.scale) as f64, (y_px / self.scale) as f64)
2098     }
2099 }
2100 
2101 /// Copy an SHM buffer's pixels out for the image registry.
2102 ///
2103 /// `WPE_PIXEL_FORMAT_ARGB8888` is B,G,R,A in memory on little-endian, which
2104 /// is handed over **as BGRA** rather than swizzled: the sampler reads either
2105 /// channel order at no cost, and rearranging 35 MB of bytes per frame on the
2106 /// CPU cost 7.4 ms at this display's fullscreen size — most of a frame budget,
2107 /// spent on nothing.
2108 ///
2109 /// The destination comes from `cce_ui::vk::recycle_buffer`, so in the steady
2110 /// state this allocates nothing: a fresh frame-sized `Vec` per frame was
2111 /// another 4.5 ms, almost all of it zeroing and page faults rather than
2112 /// copying. What remains is one memcpy per row, and only when the stride
2113 /// forces it — a tight stride is copied whole.
2114 ///
2115 /// Called from `pump`, never from the frame callback: a buffer superseded
2116 /// before the next pump is never read at all.
2117 ///
2118 /// The stride is not assumed to equal `width * 4`.
2119 /// WebKit features every webview runs with, off by default in this build.
2120 ///
2121 /// * `PropagateDamagingInformation` — each frame reports what it repainted,
2122 ///   so `pump` copies only that (see `damage.rs`). Without it a page that
2123 ///   can scroll costs a whole-window copy sixty times a second while it sits
2124 ///   still, for an overlay scrollbar WebKit never stops repainting.
2125 /// * `HiddenPageCSSAnimationSuspension` — a background tab's CSS animations
2126 ///   stop. WebKit already stops its `requestAnimationFrame` (checked: 0 a
2127 ///   second hidden), but not this, and not its timers. A hidden Muji page
2128 ///   measured 67% of a core with it off and 43% on.
2129 const FEATURES: &[(&str, bool)] =
2130     &[("PropagateDamagingInformation", true), ("HiddenPageCSSAnimationSuspension", true)];
2131 
2132 unsafe fn set_features(wv: *mut WebKitWebView) {
2133     let settings = webkit_web_view_get_settings(wv);
2134     let list = webkit_settings_get_all_features();
2135     for &(name, on) in FEATURES {
2136         let found = (0..webkit_feature_list_get_length(list))
2137             .map(|i| webkit_feature_list_get(list, i))
2138             .find(|&f| from_cstr(webkit_feature_get_identifier(f)).as_deref() == Some(name));
2139         match found {
2140             Some(f) => webkit_settings_set_feature_enabled(settings, f, on as gboolean),
2141             // A WebKit upgrade renamed or dropped it: the browser still
2142             // works, it just loses what the feature bought.
2143             None => log::warn!("WebKit has no feature {name}; leaving it as it is"),
2144         }
2145     }
2146     webkit_feature_list_unref(list);
2147 }
2148 
2149 /// A mapped SHM frame: where its pixels are, and how they are laid out.
2150 /// Borrowed from the buffer, so it must not outlive the buffer's release.
2151 struct ShmFrame {
2152     data: *const u8,
2153     stride: usize,
2154     width: u32,
2155     height: u32,
2156 }
2157 
2158 impl ShmFrame {
2159     /// The buffer's pixels, if it is an SHM buffer with all its rows there.
2160     unsafe fn of(buffer: *mut WPEBuffer) -> Option<Self> {
2161         if g_type_check_instance_is_a(buffer as *mut GTypeInstance, wpe_buffer_shm_get_type()) == 0 {
2162             return None;
2163         }
2164         let shm = buffer as *mut WPEBufferSHM;
2165         let (width, height) = (
2166             wpe_buffer_get_width(buffer) as u32,
2167             wpe_buffer_get_height(buffer) as u32,
2168         );
2169         let mut len: u64 = 0;
2170         let data = g_bytes_get_data(wpe_buffer_shm_get_data(shm), &mut len as *mut u64) as *const u8;
2171         if data.is_null() || width == 0 || height == 0 {
2172             return None;
2173         }
2174         let stride = wpe_buffer_shm_get_stride(shm) as usize;
2175         if (len as usize) < stride * (height as usize - 1) + width as usize * 4 {
2176             return None;
2177         }
2178         Some(Self { data, stride, width, height })
2179     }
2180 
2181     /// The whole frame, tightly packed, in a recycled buffer.
2182     unsafe fn copy_all(&self) -> Vec<u8> {
2183         let row = self.width as usize * 4;
2184         let need = row * self.height as usize;
2185         let mut out = cce_ui::vk::recycle_buffer(need);
2186         if self.stride == row {
2187             std::ptr::copy_nonoverlapping(self.data, out.as_mut_ptr(), need);
2188         } else {
2189             for y in 0..self.height as usize {
2190                 std::ptr::copy_nonoverlapping(
2191                     self.data.add(y * self.stride),
2192                     out.as_mut_ptr().add(y * row),
2193                     row,
2194                 );
2195             }
2196         }
2197         out
2198     }
2199 }
2200 
2201 /// `CCE_BROWSER_DAMAGE_CHECK`: patch the tab's CPU copy with the regions just
2202 /// read, as its image is being patched, and compare the result with the
2203 /// engine's whole frame. A mismatch means the damage left something out and
2204 /// the page on screen is stale there; the copy is then resynced so one miss
2205 /// is not reported on every frame after it.
2206 unsafe fn check_regions(
2207     mirror: &mut [u8],
2208     shm: &ShmFrame,
2209     packed: &[u8],
2210     regions: &[super::damage::Rect],
2211     tab: usize,
2212 ) {
2213     let row = shm.width as usize * 4;
2214     let mut at = 0usize;
2215     for &(x, y, w, h) in regions {
2216         let len = w as usize * 4;
2217         for r in 0..h as usize {
2218             let dst = (y as usize + r) * row + x as usize * 4;
2219             mirror[dst..dst + len].copy_from_slice(&packed[at..at + len]);
2220             at += len;
2221         }
2222     }
2223     let truth = shm.copy_all();
2224     let wrong = mirror
2225         .chunks_exact(4)
2226         .zip(truth.chunks_exact(4))
2227         .filter(|(a, b)| a != b)
2228         .count();
2229     if wrong > 0 {
2230         log::warn!(
2231             "damage check: tab {tab} is stale in {wrong} pixels after reading {} region(s) {regions:?}",
2232             regions.len()
2233         );
2234         mirror.copy_from_slice(&truth);
2235     } else {
2236         log::info!("damage check: tab {tab} exact after {} region(s)", regions.len());
2237     }
2238 }
2239 
2240 unsafe fn from_cstr(p: *const c_char) -> Option<String> {
2241     (!p.is_null())
2242         .then(|| std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned())
2243         .filter(|s| !s.is_empty())
2244 }
2245 
2246 
2247 /// Same inverting stylesheet the Servo backend uses, and for the same reason:
2248 /// it is the only thing that darkens a page shipping a hardcoded white with no
2249 /// `prefers-color-scheme` rule to honour.
2250 const FORCE_DARK_CSS: &str = "\
2251 html { background-color: #ffffff !important; filter: invert(1) hue-rotate(180deg) !important; }
2252 img, video, picture, canvas, svg, iframe, embed, object,
2253 [style*=\"background-image\"], [style*=\"background:url\"] {
2254   filter: invert(1) hue-rotate(180deg) !important;
2255 }
2256 ";
2257 
2258 /// Serves a `cce:` page. Runs on the main thread, unlike the Servo handler
2259 /// which runs on fetch threads — the `Arc<Mutex<_>>` stores are shared with
2260 /// that backend and stay as they are.
2261 unsafe extern "C" fn on_cce_request(request: *mut WebKitURISchemeRequest, data: gpointer) {
2262     let protocol = &*(data as *const crate::pages::CceProtocol);
2263     let uri = from_cstr(webkit_uri_scheme_request_get_uri(request)).unwrap_or_default();
2264     match protocol.route(&uri) {
2265         Some(html) => {
2266             let len = html.len() as i64;
2267             let bytes = html.into_bytes().into_boxed_slice();
2268             let ptr = Box::into_raw(bytes) as *mut c_void;
2269             // The stream owns the buffer and frees it with g_free, so the box
2270             // is deliberately leaked into it rather than dropped here.
2271             let stream = g_memory_input_stream_new_from_data(ptr, len, Some(free_boxed));
2272             let ctype = cstr("text/html; charset=utf-8");
2273             webkit_uri_scheme_request_finish(request, stream, len, ctype.as_ptr());
2274             g_object_unref(stream as *mut _);
2275         }
2276         None => {
2277             let msg = cstr(&format!("no such cce: page: {uri}"));
2278             let err = g_error_new_literal(1, 0, msg.as_ptr());
2279             webkit_uri_scheme_request_finish_error(request, err);
2280             g_error_free(err);
2281         }
2282     }
2283 }
2284 
2285 unsafe extern "C" fn free_boxed(p: gpointer) {
2286     drop(Box::from_raw(p as *mut u8));
2287 }
2288 
2289 /// Shared with WebKit's download signals for the life of the process.
2290 struct DownloadCtx {
2291     downloads: std::sync::Arc<crate::downloads::Downloads>,
2292     started: Rc<Cell<bool>>,
2293 }
2294 
2295 /// Per-download state, owned by that download's own signal closures.
2296 struct OneDownload {
2297     ctx: Rc<DownloadCtx>,
2298     id: Cell<u64>,
2299 }
2300 
2301 unsafe extern "C" fn on_download_started(
2302     _session: *mut GObject,
2303     download: *mut WebKitDownload,
2304     data: gpointer,
2305 ) {
2306     let ctx = &*(data as *const DownloadCtx);
2307     let one = Rc::new(OneDownload {
2308         ctx: Rc::new(DownloadCtx {
2309             downloads: ctx.downloads.clone(),
2310             started: ctx.started.clone(),
2311         }),
2312         id: Cell::new(u64::MAX),
2313     });
2314     ctx.started.set(true);
2315 
2316     for (sig, cb) in [
2317         (
2318             "decide-destination",
2319             on_decide_destination as *const () as usize,
2320         ),
2321         ("received-data", on_received_data as *const () as usize),
2322         ("finished", on_finished as *const () as usize),
2323         ("failed", on_failed as *const () as usize),
2324     ] {
2325         let name = cstr(sig);
2326         g_signal_connect_data(
2327             download as *mut _,
2328             name.as_ptr(),
2329             Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
2330             Rc::into_raw(one.clone()) as gpointer,
2331             Some(drop_one_download),
2332             0,
2333         );
2334     }
2335 }
2336 
2337 unsafe extern "C" fn drop_one_download(data: gpointer, _c: *mut GClosure) {
2338     drop(Rc::from_raw(data as *const OneDownload));
2339 }
2340 
2341 /// WebKit asks where to put it, passing the name the *server* suggested —
2342 /// `Content-Disposition` when present, which the extension sniff could never
2343 /// see. Returning TRUE means we handled it.
2344 unsafe extern "C" fn on_decide_destination(
2345     download: *mut WebKitDownload,
2346     suggested: *const c_char,
2347     data: gpointer,
2348 ) -> gboolean {
2349     let one = &*(data as *const OneDownload);
2350     let name = from_cstr(suggested).unwrap_or_else(|| "download".into());
2351     let path = crate::downloads::Downloads::destination_for(&name);
2352 
2353     let total = {
2354         let response = webkit_download_get_response(download);
2355         (!response.is_null())
2356             .then(|| webkit_uri_response_get_content_length(response))
2357             .filter(|n| *n > 0)
2358     };
2359     let uri = from_cstr(webkit_download_get_destination(download)).unwrap_or_default();
2360     one.id
2361         .set(one.ctx.downloads.adopt(uri, path.clone(), total));
2362 
2363     let dest = cstr(&path.to_string_lossy());
2364     webkit_download_set_destination(download, dest.as_ptr());
2365     1
2366 }
2367 
2368 unsafe extern "C" fn on_received_data(
2369     download: *mut WebKitDownload,
2370     _len: u64,
2371     data: gpointer,
2372 ) {
2373     let one = &*(data as *const OneDownload);
2374     if one.id.get() != u64::MAX {
2375         one.ctx.downloads.set_progress(
2376             one.id.get(),
2377             webkit_download_get_received_data_length(download),
2378             None,
2379         );
2380     }
2381 }
2382 
2383 unsafe extern "C" fn on_finished(_d: *mut WebKitDownload, data: gpointer) {
2384     let one = &*(data as *const OneDownload);
2385     if one.id.get() != u64::MAX {
2386         one.ctx.downloads.set_finished(one.id.get(), Ok(()));
2387     }
2388 }
2389 
2390 unsafe extern "C" fn on_failed(_d: *mut WebKitDownload, error: *mut GError, data: gpointer) {
2391     let one = &*(data as *const OneDownload);
2392     let msg = (!error.is_null())
2393         .then(|| from_cstr((*error).message))
2394         .flatten()
2395         .unwrap_or_else(|| "download failed".into());
2396     if one.id.get() != u64::MAX {
2397         one.ctx.downloads.set_finished(one.id.get(), Err(msg));
2398     }
2399 }
2400 
2401 /// What a page is currently blocked on. At most one of each: WebKit will not
2402 /// raise a second dialog on the same view until the first is answered.
2403 #[derive(Default)]
2404 pub(super) struct Prompts {
2405     dialog: Option<(*mut WebKitScriptDialog, PendingDialog)>,
2406     auth: Option<(*mut WebKitAuthenticationRequest, PendingAuth)>,
2407     /// The page asked for a context menu; the chrome draws its own.
2408     context_menu: Option<ContextMenuInfo>,
2409     /// The `<select>` list waiting on an answer, reffed until it gets one or
2410     /// the page closes it. Never more than one: a newer one closes the last.
2411     option_menu: Option<*mut WebKitOptionMenu>,
2412     /// What that list holds, until the chrome takes it to draw.
2413     option_menu_new: Option<OptionMenuInfo>,
2414     /// Links middle-clicked in a page, oldest first, for the chrome to open
2415     /// as background tabs.
2416     background_opens: Vec<Url>,
2417     /// Login fields the account watcher reported, oldest first. A queue and
2418     /// not a slot: a blur followed by a focus is two different states, and
2419     /// collapsing them would leave the list open over the wrong field.
2420     form_events: std::collections::VecDeque<crate::wpe::formwatch::FormEvent>,
2421     /// Open fill asks, oldest first, by the asking document's token. Each
2422     /// is a reply a watcher's promise is waiting on, held with a ref, and
2423     /// every one is answered exactly once: with a credential, or with
2424     /// nothing when it is displaced or dropped.
2425     fill_asks: std::collections::VecDeque<(String, *mut WebKitScriptMessageReply)>,
2426     /// The vi focus watcher's latest word: whether the focused element takes
2427     /// text. Only the newest matters, so a slot, not a queue.
2428     vi_focus: Option<bool>,
2429     /// Answers to [`WebKitHost::vi_eval`], by the caller's tag.
2430     vi_results: std::collections::VecDeque<(u32, String)>,
2431     /// The last find-in-page outcome: the match count, 0 for none.
2432     find_result: Option<u32>,
2433 }
2434 
2435 /// What was under the pointer when the page asked for a context menu, read
2436 /// off WebKit's hit test. The chrome builds its menu from this.
2437 #[derive(Debug, Clone, Default)]
2438 pub struct ContextMenuInfo {
2439     /// `(uri, label)` when the hit was a link.
2440     pub link: Option<(String, Option<String>)>,
2441     pub image_uri: Option<String>,
2442     pub is_selection: bool,
2443     pub is_editable: bool,
2444 }
2445 
2446 /// A `<select>`'s option list, for the chrome to draw at the select.
2447 #[derive(Debug, Clone)]
2448 pub struct OptionMenuInfo {
2449     pub items: Vec<OptionItem>,
2450     /// The select's box — `(x, y, width, height)` in the view's logical
2451     /// pixels, which are the chrome's.
2452     pub anchor: (f32, f32, f32, f32),
2453 }
2454 
2455 /// One row of a select's list: an `<option>`, or an `<optgroup>`'s label.
2456 #[derive(Debug, Clone)]
2457 pub struct OptionItem {
2458     pub label: String,
2459     /// An `<optgroup>` heading: drawn, never picked.
2460     pub group_label: bool,
2461     /// An option inside an `<optgroup>`, drawn indented under its heading.
2462     pub group_child: bool,
2463     pub enabled: bool,
2464     /// The select's current value.
2465     pub selected: bool,
2466 }
2467 
2468 /// A page's `alert` / `confirm` / `prompt`, waiting on the chrome.
2469 #[derive(Debug, Clone)]
2470 pub struct PendingDialog {
2471     pub message: String,
2472     /// `Some` for `prompt`, carrying its default text; `None` otherwise.
2473     pub prompt_default: Option<String>,
2474     /// `confirm` and `beforeunload` offer a choice; `alert` only acknowledges.
2475     pub has_cancel: bool,
2476 }
2477 
2478 /// An HTTP auth challenge, waiting on the chrome.
2479 #[derive(Debug, Clone)]
2480 pub struct PendingAuth {
2481     pub host: String,
2482     pub realm: String,
2483     /// Set when the previous credentials were rejected — worth telling the
2484     /// user, since the field otherwise looks identical to the first attempt.
2485     pub retry: bool,
2486 }
2487 
2488 unsafe fn connect_raw(
2489     wv: *mut WebKitWebView,
2490     signal: &str,
2491     cb: usize,
2492     prompts: &Rc<RefCell<Prompts>>,
2493 ) {
2494     let name = cstr(signal);
2495     g_signal_connect_data(
2496         wv as *mut _,
2497         name.as_ptr(),
2498         Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
2499         Rc::into_raw(prompts.clone()) as gpointer,
2500         Some(drop_prompts_ref),
2501         0,
2502     );
2503 }
2504 
2505 /// A report from the vi focus watcher. Anything else is dropped.
2506 unsafe extern "C" fn on_vi_message(
2507     _ucm: *mut WebKitUserContentManager,
2508     value: *mut JSCValue,
2509     data: gpointer,
2510 ) {
2511     let prompts = &*(data as *const RefCell<Prompts>);
2512     let raw = jsc_value_to_string(value);
2513     let Some(json) = from_cstr(raw) else { return };
2514     g_free(raw as *mut _);
2515     if let Some(editable) = crate::vi::parse_focus(&json) {
2516         prompts.borrow_mut().vi_focus = Some(editable);
2517     }
2518 }
2519 
2520 /// A [`WebKitHost::vi_eval`] script finished: queue what it returned.
2521 unsafe extern "C" fn on_vi_eval(source: *mut GObject, res: *mut GAsyncResult, data: gpointer) {
2522     let ctx = Box::from_raw(data as *mut (Rc<RefCell<Prompts>>, u32));
2523     let mut err: *mut GError = std::ptr::null_mut();
2524     let v = webkit_web_view_evaluate_javascript_finish(source as *mut WebKitWebView, res, &mut err);
2525     let mut text = String::new();
2526     if !v.is_null() {
2527         if jsc_value_is_string(v) != 0 {
2528             let raw = jsc_value_to_string(v);
2529             text = from_cstr(raw).unwrap_or_default();
2530             g_free(raw as *mut _);
2531         }
2532         g_object_unref(v as *mut _);
2533     }
2534     if !err.is_null() {
2535         g_error_free(err);
2536     }
2537     ctx.0.borrow_mut().vi_results.push_back((ctx.1, text));
2538 }
2539 
2540 unsafe extern "C" fn on_found_text(_fc: *mut WebKitFindController, count: guint, data: gpointer) {
2541     let prompts = &*(data as *const RefCell<Prompts>);
2542     prompts.borrow_mut().find_result = Some(count);
2543 }
2544 
2545 unsafe extern "C" fn on_failed_to_find(_fc: *mut WebKitFindController, data: gpointer) {
2546     let prompts = &*(data as *const RefCell<Prompts>);
2547     prompts.borrow_mut().find_result = Some(0);
2548 }
2549 
2550 /// A message from the account watcher. Anything that does not parse as one of
2551 /// its events is dropped without comment — this is a channel the chrome acts
2552 /// on, so it accepts only what it recognizes.
2553 unsafe extern "C" fn on_account_message(
2554     _ucm: *mut WebKitUserContentManager,
2555     value: *mut JSCValue,
2556     data: gpointer,
2557 ) {
2558     let prompts = &*(data as *const RefCell<Prompts>);
2559     let raw = jsc_value_to_string(value);
2560     let Some(json) = from_cstr(raw) else { return };
2561     g_free(raw as *mut _);
2562     if let Some(event) = super::formwatch::parse_event(&json) {
2563         let mut p = prompts.borrow_mut();
2564         // A page that spins on scroll must not grow this without bound; the
2565         // chrome only ever cares about the last few.
2566         if p.form_events.len() > 8 {
2567             p.form_events.pop_front();
2568         }
2569         p.form_events.push_back(event);
2570     }
2571 }
2572 
2573 /// A login field asking to be filled. The reply is held until a pick
2574 /// answers it, or a newer ask displaces it. Returning TRUE says it will be
2575 /// answered — later, which is the point.
2576 unsafe extern "C" fn on_fill_ask(
2577     _ucm: *mut WebKitUserContentManager,
2578     value: *mut JSCValue,
2579     reply: *mut WebKitScriptMessageReply,
2580     data: gpointer,
2581 ) -> gboolean {
2582     let prompts = &*(data as *const RefCell<Prompts>);
2583     let raw = jsc_value_to_string(value);
2584     let token = from_cstr(raw);
2585     g_free(raw as *mut _);
2586     // The watcher's tokens are 24 hex digits; anything else is not one.
2587     let Some(token) =
2588         token.filter(|t| t.len() == 24 && t.bytes().all(|b| b.is_ascii_hexdigit()))
2589     else {
2590         webkit_script_message_reply_ref(reply);
2591         answer_fill(reply, None);
2592         return 1;
2593     };
2594     webkit_script_message_reply_ref(reply);
2595     let displaced: Vec<_> = {
2596         let mut p = prompts.borrow_mut();
2597         let mut out = Vec::new();
2598         p.fill_asks.retain(|(t, r)| {
2599             let same = *t == token;
2600             if same {
2601                 out.push(*r);
2602             }
2603             !same
2604         });
2605         p.fill_asks.push_back((token, reply));
2606         // Only the focused field's ask matters; a few spare cover a list
2607         // still open while focus wanders between frames.
2608         while p.fill_asks.len() > 4 {
2609             if let Some((_, r)) = p.fill_asks.pop_front() {
2610                 out.push(r);
2611             }
2612         }
2613         out
2614     };
2615     for r in displaced {
2616         answer_fill(r, None);
2617     }
2618     1
2619 }
2620 
2621 /// Answer a fill ask — with the credential's JSON, or with null — and let
2622 /// go of it.
2623 unsafe fn answer_fill(reply: *mut WebKitScriptMessageReply, value: Option<&str>) {
2624     thread_local! {
2625         /// A context to build reply values in. Any will do: the value is
2626         /// serialized across to the web process, not run here.
2627         static JSC: *mut JSCContext = unsafe { jsc_context_new() };
2628     }
2629     JSC.with(|ctx| {
2630         let v = match value {
2631             // JSON has no raw NUL — serde escapes it — so this cannot fail on
2632             // a credential.
2633             Some(s) => {
2634                 let c = cstr(s);
2635                 jsc_value_new_string(*ctx, c.as_ptr())
2636             }
2637             None => jsc_value_new_null(*ctx),
2638         };
2639         webkit_script_message_reply_return_value(reply, v);
2640         g_object_unref(v as *mut _);
2641     });
2642     webkit_script_message_reply_unref(reply);
2643 }
2644 
2645 unsafe extern "C" fn drop_prompts_ref(data: gpointer, _c: *mut GClosure) {
2646     drop(Rc::from_raw(data as *const RefCell<Prompts>));
2647 }
2648 
2649 /// Returning TRUE means *we* will answer. The dialog is reffed and held; the
2650 /// page stays blocked until `respond_dialog` closes it.
2651 unsafe extern "C" fn on_script_dialog(
2652     _wv: *mut WebKitWebView,
2653     dialog: *mut WebKitScriptDialog,
2654     data: gpointer,
2655 ) -> gboolean {
2656     let prompts = &*(data as *const RefCell<Prompts>);
2657     let kind = webkit_script_dialog_get_dialog_type(dialog);
2658     let message = from_cstr(webkit_script_dialog_get_message(dialog)).unwrap_or_default();
2659     let is_prompt = kind == WebKitScriptDialogType::WEBKIT_SCRIPT_DIALOG_PROMPT;
2660     let pending = PendingDialog {
2661         message,
2662         prompt_default: is_prompt
2663             .then(|| from_cstr(webkit_script_dialog_prompt_get_default_text(dialog)))
2664             .flatten()
2665             .or_else(|| is_prompt.then(String::new)),
2666         has_cancel: kind != WebKitScriptDialogType::WEBKIT_SCRIPT_DIALOG_ALERT,
2667     };
2668     webkit_script_dialog_ref(dialog);
2669     prompts.borrow_mut().dialog = Some((dialog, pending));
2670     1
2671 }
2672 
2673 /// Same contract: TRUE means we answer, and the request is reffed until we do.
2674 unsafe extern "C" fn on_authenticate(
2675     _wv: *mut WebKitWebView,
2676     request: *mut WebKitAuthenticationRequest,
2677     data: gpointer,
2678 ) -> gboolean {
2679     let prompts = &*(data as *const RefCell<Prompts>);
2680     let pending = PendingAuth {
2681         host: from_cstr(webkit_authentication_request_get_host(request)).unwrap_or_default(),
2682         realm: from_cstr(webkit_authentication_request_get_realm(request)).unwrap_or_default(),
2683         retry: webkit_authentication_request_is_retry(request) != 0,
2684     };
2685     g_object_ref(request as *mut _);
2686     prompts.borrow_mut().auth = Some((request, pending));
2687     1
2688 }
2689 
2690 /// A navigation is about to happen. A middle-click on a link — which WebKit
2691 /// reports as an ordinary navigation (or a new-window one, for a
2692 /// `target=_blank` link) carrying the button — is diverted to a background
2693 /// tab: the decision is ignored here and the URL queued for the chrome.
2694 /// Everything else gets WebKit's default.
2695 unsafe extern "C" fn on_decide_policy(
2696     _wv: *mut WebKitWebView,
2697     decision: *mut WebKitPolicyDecision,
2698     kind: WebKitPolicyDecisionType::Type,
2699     data: gpointer,
2700 ) -> gboolean {
2701     if kind != WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION
2702         && kind != WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION
2703     {
2704         return 0;
2705     }
2706     let action = webkit_navigation_policy_decision_get_navigation_action(
2707         decision as *mut WebKitNavigationPolicyDecision,
2708     );
2709     if action.is_null()
2710         || webkit_navigation_action_get_mouse_button(action) != 2
2711         || webkit_navigation_action_get_navigation_type(action)
2712             != WebKitNavigationType::WEBKIT_NAVIGATION_TYPE_LINK_CLICKED
2713     {
2714         return 0;
2715     }
2716     let request = webkit_navigation_action_get_request(action);
2717     let Some(url) = (!request.is_null())
2718         .then(|| from_cstr(webkit_uri_request_get_uri(request)))
2719         .flatten()
2720         .and_then(|u| Url::parse(&u).ok())
2721     else {
2722         return 0;
2723     };
2724     webkit_policy_decision_ignore(decision);
2725     let prompts = &*(data as *const RefCell<Prompts>);
2726     prompts.borrow_mut().background_opens.push(url);
2727     1
2728 }
2729 
2730 /// The page asked for a context menu. Stash what the hit test says was under
2731 /// the pointer and claim presentation; the chrome draws the menu at the
2732 /// pointer position it already tracks (the hit test carries no coordinates).
2733 unsafe extern "C" fn on_context_menu(
2734     _wv: *mut WebKitWebView,
2735     _menu: *mut WebKitContextMenu,
2736     hit: *mut WebKitHitTestResult,
2737     data: gpointer,
2738 ) -> gboolean {
2739     let prompts = &*(data as *const RefCell<Prompts>);
2740     let mut info = ContextMenuInfo::default();
2741     if !hit.is_null() {
2742         if webkit_hit_test_result_context_is_link(hit) != 0 {
2743             if let Some(uri) = from_cstr(webkit_hit_test_result_get_link_uri(hit)) {
2744                 info.link = Some((uri, from_cstr(webkit_hit_test_result_get_link_label(hit))));
2745             }
2746         }
2747         if webkit_hit_test_result_context_is_image(hit) != 0 {
2748             info.image_uri = from_cstr(webkit_hit_test_result_get_image_uri(hit));
2749         }
2750         info.is_selection = webkit_hit_test_result_context_is_selection(hit) != 0;
2751         info.is_editable = webkit_hit_test_result_context_is_editable(hit) != 0;
2752     }
2753     prompts.borrow_mut().context_menu = Some(info);
2754     1
2755 }
2756 
2757 /// A `<select>` asked to open. Read its options and where it is, hold the
2758 /// menu, and claim it: the chrome draws the list and answers with
2759 /// `pick_option` or `close_option_menu`. Returning FALSE would leave it to
2760 /// WebKit's default, which on WPE is nothing at all.
2761 unsafe extern "C" fn on_show_option_menu(
2762     _wv: *mut WebKitWebView,
2763     menu: *mut WebKitOptionMenu,
2764     rect: *mut WebKitRectangle,
2765     data: gpointer,
2766 ) -> gboolean {
2767     let prompts = &*(data as *const RefCell<Prompts>);
2768     let items = (0..webkit_option_menu_get_n_items(menu))
2769         .map(|i| {
2770             let item = webkit_option_menu_get_item(menu, i);
2771             OptionItem {
2772                 label: from_cstr(webkit_option_menu_item_get_label(item)).unwrap_or_default(),
2773                 group_label: webkit_option_menu_item_is_group_label(item) != 0,
2774                 group_child: webkit_option_menu_item_is_group_child(item) != 0,
2775                 enabled: webkit_option_menu_item_is_enabled(item) != 0,
2776                 selected: webkit_option_menu_item_is_selected(item) != 0,
2777             }
2778         })
2779         .collect();
2780     let anchor = if rect.is_null() {
2781         (0.0, 0.0, 0.0, 0.0)
2782     } else {
2783         let r = &*rect;
2784         (r.x as f32, r.y as f32, r.width as f32, r.height as f32)
2785     };
2786     g_object_ref(menu as *mut _);
2787     // The page can close the list itself (the select goes away, the page
2788     // navigates); hearing that is how the chrome's copy comes down too.
2789     let name = cstr("close");
2790     g_signal_connect_data(
2791         menu as *mut _,
2792         name.as_ptr(),
2793         Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
2794             on_option_menu_close as *const () as usize,
2795         )),
2796         // The close handler's own reference, dropped with the connection.
2797         {
2798             Rc::increment_strong_count(data as *const RefCell<Prompts>);
2799             data
2800         },
2801         Some(drop_prompts_ref),
2802         0,
2803     );
2804     let previous = {
2805         let mut p = prompts.borrow_mut();
2806         p.option_menu_new = Some(OptionMenuInfo { items, anchor });
2807         p.option_menu.replace(menu)
2808     };
2809     // Closed outside the borrow: its close handler borrows the prompts.
2810     if let Some(old) = previous {
2811         webkit_option_menu_close(old);
2812         g_object_unref(old as *mut _);
2813     }
2814     1
2815 }
2816 
2817 /// A held list was closed — by the page, or by our own `close`. Only the
2818 /// first case finds it still held; ours took it out before closing. WebKit
2819 /// keeps its own reference across the emission, so dropping ours here is
2820 /// safe.
2821 unsafe extern "C" fn on_option_menu_close(menu: *mut WebKitOptionMenu, data: gpointer) {
2822     let prompts = &*(data as *const RefCell<Prompts>);
2823     let mut p = prompts.borrow_mut();
2824     if p.option_menu == Some(menu) {
2825         p.option_menu = None;
2826         p.option_menu_new = None;
2827         drop(p);
2828         g_object_unref(menu as *mut _);
2829     }
2830 }