Skip to content

linux: Add support for embedded Ozone/Wayland windows - #4233

Open
leandromqrs wants to merge 5 commits into
chromiumembedded:masterfrom
leandromqrs:feat/wayland-embed-support
Open

linux: Add support for embedded Ozone/Wayland windows#4233
leandromqrs wants to merge 5 commits into
chromiumembedded:masterfrom
leandromqrs:feat/wayland-embed-support

Conversation

@leandromqrs

Copy link
Copy Markdown

Adds the native windowed mode on Ozone/Wayland: a browser hosted inside a window the client application owns, the way CefWindowInfo::SetAsChild already works on X11. This is issue #2804.

Before deciding whether to read the rest: this needs a decision from you before it can be finished, and it is the first item in the dependency list in that issue. The API question is the blocker, not the code.

Some context first: I posted a demo and the branch in the issue before opening this (#2804 (comment)), so this is not arriving cold. But the approach itself was not discussed there before the work started, which CONTRIBUTING asks for on architectural changes, and that was a mistake on my part. The branch exists as something concrete to react to, not as a decision already made. If the API shape below is wrong, it changes.

Demonstration

saida.webm

What is here

Five commits. The first is a Chromium patch; the rest are CEF.

chromium: Add Wayland subsurface embedding to Ozone patch/patches/ozone_wayland_embed_2804.patch
linux: Add Wayland API and runtime platform selection public API, cef_ozone::IsWayland()
linux: Create windowed browsers on Wayland browser_platform_delegate_native_linux.cc
linux: Add a Wayland embedding sample tests/cefembed_wayland
docs: Add design notes for Wayland embedding docs/wayland_embedding.md

The design and the reasoning behind each decision are in docs/wayland_embedding.md. This description covers what you need to decide and what you should not trust.

The API decision

cef_window_info_t::parent_window carries the client's wl_surface. No new field: that member has always been an opaque native parent handle whose meaning depends on the platform (an HWND on Windows, an NSView* on macOS), and the unsigned long spelling on Linux is an artifact of X11 that holds a pointer on LP64. Existing embedders keep compiling and keep working.

The cost is the failure mode. A valid XID such as 0x2600004 is numerically indistinguishable from an address, so passing the wrong handle for the active platform is undefined rather than diagnosed. In practice the handle comes from the client's toolkit, which knows which platform it is on. If you would rather have a separate field and a diagnosable error, that is a reasonable call and this branch should change.

One member is appended, under CEF_API_ADDED(CEF_EXPERIMENTAL):

cef_xdg_surface_handle_t parent_xdg_surface;  // in, for popups

with a three-argument SetAsChild(parent, parent_xdg, bounds) alongside the existing form. May be null; popups then fall back to a degraded subsurface form, described in the docs.

An earlier revision also returned the browser's own wl_surface as an output member. It was removed, because a wl_surface is scoped to the connection the embedder already owns, so it told the embedder nothing it could act on.

The connection is passed process-wide, not per browser, because Chromium builds its WaylandConnection while initializing Ozone, inside CefInitialize and long before any CefWindowInfo exists:

CEF_EXPORT void cef_set_wayland_display(cef_wayland_display_handle_t display);

The 2020 Igalia prototype put this on CefWindowInfo; that cannot work for the reason above.

CefBrowserHost::SetWindowBounds() is new and unavoidable: a wl_subsurface receives no configure events, so the client is the only source of layout. It is declared last in the class on purpose, because the translator moves versioned members to the end of the generated struct in declaration order, and putting it earlier would shift the existing experimental set_ax_viewport_collapse.

The Chromium patch

This is what makes it not a small pull request. patch/patches/ozone_wayland_embed_2804.patch is the largest patch in the tree and the first to add files rather than only edit them. It shares the embedder's wl_display, because a wl_surface is scoped to one connection and wl_subcompositor_get_subsurface() rejects surfaces from another, and it adds a WaylandWindow subclass attached as a wl_subsurface of a bare surface with no WaylandWindow behind it. The rest is in the docs.

One thing from it belongs here rather than in the docs, because it is a bug and not a design decision. RootWindowFromWlSurface() used to read any wl_surface's user data and cast it to ui::WaylandSurface, which on a shared connection is a pointer owned by the client's toolkit. Reproduced as a GPF by moving the mouse over a client's CSD borders. The patch splits the lookup by class so that only Chromium's own surfaces resolve.

Whether the patch belongs in CEF or upstream in Chromium is your call; I have no standing to make it. I understand what carrying it costs: it has to be rebased at every milestone, and it touches files that churn. I am willing to keep it rebased for as long as it is useful. Some of it may not need to be carried at all: the surface lookup fix above is independent of embedding and could be proposed to Chromium on its own.

What it does:

  • WaylandConnection adopts an externally owned wl_display. A wl_surface is scoped to one connection and wl_subcompositor_get_subsurface() rejects surfaces from another, so there is no way to parent into the client's surface without sharing the connection. Smaller than expected, since Chromium already routes its traffic through a dedicated wl_event_queue.
  • WaylandEmbeddedWindow, a WaylandWindow attached as a wl_subsurface of a bare wl_surface with no WaylandWindow behind it. Registered in WaylandWindowManager like any other, which is what makes accelerated-widget lookup resolve.
  • Input split by class. RootWindowFromWlSurface() resolves only Chromium's own surfaces; only the keyboard and text input cross over from the embedder's, through a separate lookup. This also closes a latent memory-safety bug: that function used to read any wl_surface's user data and cast it to ui::WaylandSurface, which on a shared connection is a pointer owned by the client's toolkit. Reproduced as a GPF by moving the mouse over a client's CSD borders.
  • Activation derived from the seat and notified through the window manager, exactly as WaylandToplevelWindow does it.

The design tries hard to have no special cases. Where it diverges from the toplevel, the comment says why.

Verified

Fedora 44, GNOME 50 Wayland, two monitors at different scales, against a local build of this branch.

Rendering inside the client's window, pointer, keyboard, <select> and context menus, resize, close. IME with ibus and Intelligent Pinyin: preedit underlined in the field, candidate window next to the caret, commit landing in the input. Drag and drop in both directions, including drops over the client's own chrome still going to the client. HiDPI with fractional scaling, starting on either kind of output and dragging between them. Several browsers in one window, created, moved, hidden and closed independently, with keyboard focus following both SetFocus() and a click.

The X11 path is unchanged behaviour with the same binary.

Most of that was exercised with a separate host application rather than the sample in this branch:

https://github.com/leandromqrs/cef-wayland-embed-sample

It is an interactive suite in Rust over winit that creates and destroys browsers at runtime in all three CEF hosting modes, moves and resizes panels independently, and reports what the host saw next to what the browser saw. It also runs against stock CEF, which is what makes the comparison meaningful. The C++ tests/cefembed_wayland in this branch is the minimal reference; that one is the test rig.

34 unit tests across both layers: 30 in Ozone on the WaylandTest harness, 4 for the cef_window_info_t ABI. The 476 Wayland tests in the tree still pass with the patch applied. Each new test was verified by reintroducing the defect it covers.

Not verified

The macOS build. No machine available. Audited instead: of the 18 files this branch touches, 12 are either not compiled at all or excluded by if (is_linux) and #if defined(OS_LINUX). The six that remain hold exactly one platform-guarded symbol, SetHostBounds, guarded identically in its declaration, its definition and its call site, and SetWindowBounds, which is pure virtual in cef_browser.h but has a single implementation in a cross-platform file. The residual risk is a compile error, not a runtime defect. It is not zero, though, and your CI will find it faster than I can.

Known limitations

Two, both with their causes and the reasoning in the docs. Resizing lags the client by a frame, because wl_subsurface.set_position is double-buffered on the parent surface and the two surfaces are submitted in separate compositor frames; WaylandBubble carries the same TODO upstream (https://crbug.com/329145822). And where the client exposes no xdg_surface, popups fall back to a wl_subsurface, which cannot extend past the client's window or hold a grab, so a dropdown near an edge is clipped and clicking outside does not dismiss it. Failing instead is not available, because WaylandWindow::Create() returning null makes views destroy the widget inside Widget::Show(), which CHECKs. Both CEF and Ozone log a warning naming the limitation when the fallback is taken.

On how this was written

Written with heavy AI assistance, disclosed in the commit trailers. I take responsibility for all of it: I can explain any decision here, and the reasoning behind each is either in docs/wayland_embedding.md or in the comment next to the code.

…dded#2804)

Adds the Chromium-side half of hosting a browser inside a wl_surface owned by
another application. Carried as a patch because none of it can live in CEF.

A wl_surface is a protocol object scoped to one wl_display, and
wl_subcompositor_get_subsurface() rejects surfaces from another connection, so
WaylandConnection has to adopt the embedder's display rather than call
wl_display_connect(). Chromium already routes its own traffic through a
dedicated wl_event_queue, so the two event loops coexist without further work.

WaylandEmbeddedWindow is a WaylandWindow attached as a wl_subsurface of a bare
wl_surface with no WaylandWindow behind it. It registers with
WaylandWindowManager like any other window, which is what makes accelerated
widget lookup resolve -- and is precisely what used to crash when the X11 path
ran under Ozone/Wayland.

Input is split by class. RootWindowFromWlSurface() resolves only Chromium's own
surfaces and now returns null for anything else, because pointer, touch, tablet
and drag events on the embedder's surface arrive in that application's
coordinate space and belong to it. Only the keyboard and text input cross over,
through KeyboardWindowFromWlSurface(), since wl_keyboard.enter is delivered to
the embedder's toplevel and a wl_subsurface never receives it. That event fires
once and is not repeated, so Activate() takes the stored focus over from a
sibling, and a press inside an embedded window claims it -- the click-to-focus
policy a compositor applies to toplevels and that a subsurface is invisible to.

Popups anchor on the embedder's xdg_surface, passed in by the host. There is no
protocol alternative: xdg_surface.get_popup requires an xdg_surface, and
xdg_foreign rejects anything that is not an xdg_toplevel equivalent. Where the
embedder exposes none, a menu falls back to a wl_subsurface, which cannot leave
the host window or hold a grab but does render -- failing instead is not an
option, because views destroys the widget inside Widget::Show() and CHECKs.

The lookup also closes a memory-safety hole: it used to read a wl_surface's
user data and cast it to ui::WaylandSurface, which on a shared connection is a
pointer owned by the embedder's toolkit.

Covered by wayland_embedded_window_unittest.cc.

Assisted-by: Claude Code
…bedded#2804)

Everything an embedder needs to name its Wayland objects, plus the runtime
check that decides which Ozone platform is actually in use.

The parent surface is not a new field. cef_window_info_t::parent_window has
always been an opaque native parent handle whose meaning depends on the
platform -- an HWND on Windows, an NSView* on macOS -- so under Ozone/Wayland
it names a wl_surface. The unsigned long spelling on Linux is an artifact of
X11 and holds a pointer on LP64, so existing embedders keep compiling and
keep working. The cost is that a mistaken XID cannot be told from a valid
address, which is documented rather than diagnosed.

parent_xdg_surface is appended under CEF_API_ADDED(CEF_EXPERIMENTAL), with a
three-argument SetAsChild() overload. It may be null: toolkits differ in
whether they expose the xdg_surface at all, and refusing to create the browser
would put embedding out of reach for those hosts.

Appending is only half of ABI safety, so the copy is guarded by
CEF_MEMBER_EXISTS: |size| is the only thing that distinguishes a smaller
struct at runtime, and reading the member without checking it reads past the
end of an older application's allocation. The else branch matters as much --
CefStructBase::Set() calls Clear() to "clear newer members that won't be set",
but clear() only frees string members, so a stale pointer would otherwise
survive being assigned over.

The connection is passed process-wide through cef_set_wayland_display() rather
than per browser, because Chromium builds its WaylandConnection while
initializing Ozone, inside CefInitialize and long before any CefWindowInfo
exists. The 2020 Igalia prototype put it on CefWindowInfo, which cannot work
for that reason.

CefBrowserHost::SetWindowBounds() lets the host reposition an embedded browser,
which it must: a wl_subsurface receives no configure events, so layout can only
come from outside. It is declared last in the class because the translator
moves versioned members to the end of the generated struct in declaration
order, and putting it earlier would shift the existing experimental
set_ax_viewport_collapse.

cef_ozone::IsWayland()/IsX11() read the platform Ozone actually selected. A
single libcef is built with both compiled in, so BUILDFLAG(SUPPORTS_OZONE_X11)
only says the code exists, not that it is usable.

Covered by window_info_linux_unittest.cc.

Assisted-by: Claude Code
CreateHostWindow() used to select CefWindowX11 on BUILDFLAG(SUPPORTS_OZONE_X11)
alone. Release binaries compile in both Ozone platforms, so that path also ran
under Ozone/Wayland and produced a stray XWayland window, or nothing at all
followed by a crash in WaylandWindowManager on the first event dispatch. The
X11 code is now behind cef_ozone::IsX11(), unchanged, and an unsupported
platform fails with a message instead of returning true having created nothing.

The Wayland path registers the client's wl_surface with Ozone, which hands back
a gfx::AcceleratedWidget. That widget is the only vocabulary views has for a
native parent -- it travels through Widget::InitParams::parent_widget -- and
WaylandWindow::Create() resolves it back into the surface. No CEF-side
counterpart of CefWindowX11 is needed: that class exists because
DesktopWindowTreeHostLinux cannot be reparented into a foreign X11 window,
whereas here the embedded window is the platform window itself.

Closing had to be driven explicitly. Widget::Close() ends in a callback that
only clears the pointer; the three existing platforms complete the teardown by
calling AlloyBrowserHostImpl::WindowDestroyed() from their own window
destruction, and Wayland has nothing equivalent to hang that on. Without it
DestroyBrowser() never ran, OnBeforeClose never fired, and CefShutdown() went
on to destroy WaylandConnection underneath a live browser.

SetFocus() now drives both directions. Under X11 the window manager takes focus
away by itself, so blur never needed work here; an embedded browser has no
window the compositor knows about, and a host with two browsers could otherwise
ask for focus but never give it up.

Validation happens before host_window_created_ and the AddRef(), since
BrowserDestroyed() -- which undoes both -- is not called for a browser that
never finished being created.

Assisted-by: Claude Code
cefclient's Linux windowing path is GTK over X11 and hands CEF an XID, so
there was no sample exercising native windowed mode on Wayland at all. This
one opens a wl_display, hands it to CEF before CefInitialize, creates an
xdg_toplevel, paints a plain host UI into it with wl_shm, and embeds a browser
as a subsurface of that surface -- all from one thread.

The content rectangle is painted in a deliberately loud colour first, so a
browser that fails to appear leaves a visible hole rather than failing
silently.

Two details are the point rather than incidental. It drives both event loops
from one thread with wl_display_prepare_read/read_events and a poll timeout,
which is the canonical way to share a connection between two dispatchers. And
it selects --ozone-platform=wayland on the command line rather than through
CefSettings, because subprocesses inherit argv and have to agree with the
browser process; without it Ozone picks X11 wherever DISPLAY is set and reads
the wl_surface in parent_window as an X11 Window.

GNOME does not advertise zxdg_decoration_manager_v1, so the client draws its
own decorations and drives moving and resizing itself.

Assisted-by: Claude Code
Records why the design is shaped the way it is, so the next person does not
rediscover it: that a wl_surface cannot cross a connection and everything else
follows from that, why the connection is process-wide rather than per browser,
why popups borrow the embedder's xdg_surface when no protocol allows anchoring
to a subsurface, and what an embedder has to do differently from X11.

Also records what is not done. IME routing exists but nothing past it has been
exercised -- preedit, dead keys, CJK composition, and where a candidate window
lands when the window it belongs to is a subsurface. Drag and drop and HiDPI
are untested. The resize lag is inherent: wl_subsurface.set_position is
double-buffered on the parent surface, so a move only takes effect on the
embedder's next commit, and WaylandBubble carries the same TODO upstream
(crbug.com/329145822).

Assisted-by: Claude Code
@leandromqrs
leandromqrs force-pushed the feat/wayland-embed-support branch from 7636d8d to baeaedf Compare August 4, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant