linux: Add support for embedded Ozone/Wayland windows - #4233
Open
leandromqrs wants to merge 5 commits into
Open
Conversation
…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
force-pushed
the
feat/wayland-embed-support
branch
from
August 4, 2026 00:58
7636d8d to
baeaedf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the native windowed mode on Ozone/Wayland: a browser hosted inside a window the client application owns, the way
CefWindowInfo::SetAsChildalready 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 Ozonepatch/patches/ozone_wayland_embed_2804.patchlinux: Add Wayland API and runtime platform selectioncef_ozone::IsWayland()linux: Create windowed browsers on Waylandbrowser_platform_delegate_native_linux.cclinux: Add a Wayland embedding sampletests/cefembed_waylanddocs: Add design notes for Wayland embeddingdocs/wayland_embedding.mdThe 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_windowcarries the client'swl_surface. No new field: that member has always been an opaque native parent handle whose meaning depends on the platform (anHWNDon Windows, anNSView*on macOS), and theunsigned longspelling 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
0x2600004is 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):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_surfaceas an output member. It was removed, because awl_surfaceis 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
WaylandConnectionwhile initializing Ozone, insideCefInitializeand long before anyCefWindowInfoexists:The 2020 Igalia prototype put this on
CefWindowInfo; that cannot work for the reason above.CefBrowserHost::SetWindowBounds()is new and unavoidable: awl_subsurfacereceives 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 experimentalset_ax_viewport_collapse.The Chromium patch
This is what makes it not a small pull request.
patch/patches/ozone_wayland_embed_2804.patchis the largest patch in the tree and the first to add files rather than only edit them. It shares the embedder'swl_display, because awl_surfaceis scoped to one connection andwl_subcompositor_get_subsurface()rejects surfaces from another, and it adds aWaylandWindowsubclass attached as awl_subsurfaceof a bare surface with noWaylandWindowbehind 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 anywl_surface's user data and cast it toui::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:
WaylandConnectionadopts an externally ownedwl_display. Awl_surfaceis scoped to one connection andwl_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 dedicatedwl_event_queue.WaylandEmbeddedWindow, aWaylandWindowattached as awl_subsurfaceof a barewl_surfacewith noWaylandWindowbehind it. Registered inWaylandWindowManagerlike any other, which is what makes accelerated-widget lookup resolve.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 anywl_surface's user data and cast it toui::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.WaylandToplevelWindowdoes 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 bothSetFocus()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
winitthat 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_waylandin this branch is the minimal reference; that one is the test rig.34 unit tests across both layers: 30 in Ozone on the
WaylandTestharness, 4 for thecef_window_info_tABI. 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, andSetWindowBounds, which is pure virtual incef_browser.hbut 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_positionis double-buffered on the parent surface and the two surfaces are submitted in separate compositor frames;WaylandBubblecarries the same TODO upstream (https://crbug.com/329145822). And where the client exposes noxdg_surface, popups fall back to awl_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, becauseWaylandWindow::Create()returning null makes views destroy the widget insideWidget::Show(), whichCHECKs. 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.mdor in the comment next to the code.