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