Skip to content

feat(macos): inline xwidget web views via native WKWebView - #297

Merged
eval-exec merged 11 commits into
eval-exec:mainfrom
tag-und-nacht:feature/xwidget-macos
Aug 30, 2026
Merged

feat(macos): inline xwidget web views via native WKWebView#297
eval-exec merged 11 commits into
eval-exec:mainfrom
tag-und-nacht:feature/xwidget-macos

Conversation

@tag-und-nacht

@tag-und-nacht tag-und-nacht commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Adds an inline browser on macOS, using the system WKWebView instead of WPE.

This is partial support, not complete GNU Emacs xwidget compatibility.
xwidget-webkit-browse-url works end to end on the primary frame. What is not there —
secondary frames, real navigation events and measured load progress, script result
callbacks, keyboard focus — is enumerated in docs/building.md and tracked in #300.
It advances the macOS half of #22 rather than closing it.

Rebased onto main (57 commits, no conflicts) and updated for review — see
Review response at the end.

Why not WPE

I tried the WPE route first and got further than expected, so recording where it actually stops:

  • libwpe builds on aarch64-darwin. Stock nixpkgs fails on EGL/eglplatform.h, but nixpkgs angle (ANGLE over Metal, prebuilt) plus a hand-written egl.pc shim builds libwpe 1.16.3.
  • WPE WebKit 2.50.4 configures with ENABLE_WPE_LEGACY_API=OFF, headless-only, USE_GBM=OFF, USE_LIBDRM=OFF, USE_SYSTEM_SYSPROF_CAPTURE=NO. No Linux-only FATAL_ERROR fires; the full feature summary prints. The first Apple-specific stop is an install rule: "no FRAMEWORK DESTINATION for shared library FRAMEWORK target WebKit".
  • wpebackend-fdo does not build — it needs wayland-server/wayland-egl, and wayland itself fails on darwin.

None of that is the real blocker. The frame path is. neomacs-display-runtime exports frames with eglExportDMABUFImageMESA — a Mesa extension ANGLE does not implement — into wgpu via Vulkan VK_EXT_external_memory_dma_buf. macOS has no dma-buf, wgpu runs Metal, and WPEPlatform's buffer types are DMABuf/SHM/Android with no IOSurface.

ANGLE does implement an IOSurface path (IOSurfaceSurfaceMtl, EGL_ANGLE_iosurface_client_buffer), but it is unusable here because WKWebView will not render into it. GNU Emacs records why in the header comment of src/nsxwidget.m:

Webkit2 process architecture seems to be very hostile for offscreen rendering techniques, which is used by GTK xwidget implementation; Specifically NSView level view sharing / copying is not working. *** So only one view can be associated with a model. ***

So macOS gets what GNU Emacs gets: a native NSView subtree positioned over the GPU surface, not a texture composited into it.

What's here

Two commits.

1. backend/wkwebview — the native overlay and its placement.

The algorithm is GNU Emacs', ported function for function:

GNU Emacs here
produce_xwidget_glyph (xdisp.c) GlyphType::Xwidget — already existed
x_draw_xwidget_glyph_string clip block (xwidget.c:2841-2849) Placement::new
xwidget.c:2856 / :2961 Placement::moved_from / reclipped_from
nsxwidget_move_view / resize_view / move_widget_in_view WkWebView::apply
xwidget_end_redisplay (xwidget.c:4135) WkWebViewHost::sync_frame
nsxwidget_hide_view (nsxwidget.m:607) WkWebView::hide

Most of the scaffolding was already in the tree: GlyphType::Xwidget, FrameGlyph::Xwidget with its resolved rect and clip_rect, and the clipped_media_rect() intersection. What was missing was the per-frame sweep, the two dirty checks, and the Cocoa backend itself.

Like Emacs, a view is hidden by default unless the frame vouched for it, so scrolling away, switching buffers and deleting a window all work without any of those paths knowing inline web views exist.

2. Two subrs and the feature flag.

A working overlay changes nothing for a configuration that gates on (featurep 'xwidget-internal) — which is the correct thing for such a configuration to do. Auditing what lisp/xwidget.el actually reaches for on the xwidget-webkit-browse-url path showed most apparent gaps (xwidget-insert, xwidget-put, xwidget-window-inside-pixel-width) are Lisp functions in that same file. Only two were real C subrs:

  • xwidget-webkit-execute-script over evaluateJavaScript:completionHandler:. GNU's optional FUN receives the script's return value, which needs a result channel from the render thread back to the Lisp thread that does not exist here. FUN therefore signals rather than being silently dropped — a caller that passed it would otherwise wait forever for a call that never comes.
  • xwidget-webkit-estimated-load-progress, which is not measured and says so in its comment. Nothing sends InputEvent::WebKitLoadFinished (the variant exists; nothing produces or consumes it), so it is 0.0 before a navigation and 1.0 once one is dispatched. A stuck 0.0 renders a permanent [0%] in xwidget-webkit-mode's header line, which seemed the worse lie. Tracked for real plumbing in [Tracking] macOS xwidget-internal: remaining gaps after #297 #300.

xwidget-internal is now decided by a build.rs probe, neomacs_have_wkwebview, following the existing neomacs_have_lcms2 pattern — DetectedAtBuildTime requires citing a probe and cfg!(target_os) is not one. Linux keeps its old answer; its WPE path still renders through dma-buf and ledger 190's missing subrs are still open there. This is safe because xwidget.el does not require the feature — its (require 'xwidget-internal) is commented out at line 32 — so the flag is advisory to configuration, which is exactly what reads it.

Two ledger tests become platform-aware, both unchanged on Linux: the derived-feature order, and the coupled-vars absence probe, whose own docstring says that firing is the designed signal that a build gained a feature. XWIDGET_LAYER's justification is rewritten because on macOS those three variables are now bound because the feature is present, matching GNU, rather than as a policy exception.

Two things I got wrong first

Recording these because the reasons are more useful than the fix.

Placement does not belong in the renderer's draw walk. My first plan put it in content.rs, next to the existing WPE quad building. That would strand views: render_frame_window_impl has a retained-static fast path that blits a cached scene and skips the glyph pipeline entirely on compositor-only frames, so placement hooked there would never run on those frames and the sweep would hide live views. It runs in render_thread/render_pass.rs right after renderer.queue().present(output) — the true analogue of Emacs calling xwidget_end_redisplay from dispnew.c:4626 once redisplay has settled, not from paint code. Running it after the present also minimises the overlay's lag behind the composited frame.

The coordinate flip hides a bug that Emacs cannot have. Emacs makes its xwidget views flipped (nsterm.m:8540, nsxwidget.m:554) and positions them top-down. The winit content view carries no such guarantee, so geometry is computed top-down exactly as Emacs computes it and converted per host. That conversion means the clip origin depends on the visible height:

ns_y = host_height - (top_left_y + visible_height)

So a pure reclip — a window resized shorter under a widget whose top has not moved — changes the origin while moved_from reports false. Emacs gates the reposition on movement alone and is right to, because flipped coordinates decouple the two. Here it must also run on a reclip:

if moved || (reclipped && !host_flipped) { /* set origin */ }

Pinned by a_pure_reclip_moves_the_bottom_up_origin.

Verification

Tested against a real user configuration, unmodified, on a 168 KB Obsidian document with 170 math blocks:

  • my/cap-xwidgets-p answers t, zero modules skipped — no config edit needed, which was the point of moving the flag rather than weakening the predicate.
  • markdown-xwidget preview renders in a live WKWebView; MathJax typeset (equations 1, 2, 3a/3b) and Mermaid diagrams both confirmed to match vanilla Emacs.
  • JavaScript execution proved visually: an injected scrollTo plus filter: invert(1) both scrolled the preview and flipped its palette.
  • The FUN refusal path signals rather than hanging.
  • 777 neomacs-display-runtime tests pass (12 new geometry tests); feature and coupled-vars ledgers pass; clippy and rustfmt clean on new code.
  • cargo build --release links /System/Library/Frameworks/WebKit.framework.

One pre-existing failure, untouched: c_features_test::the_features_this_build_really_has_still_answer_t asserts (featurep 'inotify) and fails on any macOS build, with or without this change. I confirmed that by stashing the change and re-running, and left it alone rather than widen this PR.

Known limitations

Inherited from the overlay technique, all of which GNU Emacs has shipped for years:

  • Neomacs UI cannot paint over the web view (modeline, minibuffer, popups, cursor, overlays).
  • Scrolling lags the GPU-composited content by about a frame; AppKit view moves are not synchronised with the wgpu present.
  • No participation in animation effects; GPU capture of the surface omits web content.
  • One view per model — a web buffer cannot be shown in two windows. The guard from xwidget.c:2820-2836 is ported: the first placement wins and it warns once.

Not addressed here: keyboard focus handoff between neomacs and the web view. Mouse input works, since the view is a real subview in the responder chain.

Two things for maintainers

Both now filed, so they do not vanish when this merges:


Review response

Rebased onto main — 57 commits, git merge-tree and the rebase itself both clean. Four
commits added on top rather than squashed, so the fixes read as a diff against what was
reviewed.

1. Clip-view orientation — 5ef37c7ec

Correct, and worse than latent. attach now logs host.isFlipped(), and the measured
answer on this build is true — so the old code took the host_flipped branch and
wrote -clip_top, the right number for a flipped clip, into a clip that was a stock
NSView. A widget scrolled under the window's top edge was misplaced inside its own clip
by exactly height - visible_height, on every macOS build.

Fixed the way GNU does it, rather than by patching the formula: XwidgetClipView, a
flipped NSView subclass — the port's first define_class! — matching XvWindow
(nsxwidget.m:553). inner_origin loses its parameter and becomes (-clip_left, -clip_top),
xwidget.c:2995 unchanged. ns_origin keeps host_flipped, since that frame really is in
the host's space.

Its gate is extracted into needs_reposition so the rule is testable without AppKit, and
it grew a third case found while rewriting it: the bottom-up origin also folds in the
host's own height, which no placement diff can see, so a window resize under an
unmoved widget left a stale origin. WkWebView now remembers the host geometry it placed
against. That path is latent while winit's view stays flipped; it is implemented and
tested because that is winit's answer to change, not ours.

Red-before confirmed on both new rules: the old gate answers false where the test asserts
true, and the old inner_origin(false) answers 0 where your worked example requires
-20.

2. Commands lost before attachment — d150626cb

Correct, and there was a second half. Only WebKitCreate ever called attach, so even a
window that became available between a create and the load behind it was picked up only at
the next present — by which time poll_commands, which drains unconditionally and
independently of frame presentation, had already discarded the load.

pending now holds the commands rather than the create's size: one arrival-ordered FIFO for
all ids, drained by attach through the ordinary methods. One queue rather than one per id,
because ordering matters across ids too. destroy drops an id's queued work; at capacity the
newest is dropped so what survives is a coherent prefix rather than loads whose create was
evicted. And every WebKit arm now tries attach.

The queue is its own type with no AppKit in it (backend/wkwebview/pending.rs), so it is
unit-testable off the main thread — eight tests, red by construction, since the old structure
had nowhere to put a URL.

3. execute-script on Linux — d78bd3ffa

Correct, and the tree already contained the missing half. WebKitExecuteJavaScript carried
an identical payload and a real WPE dispatch with no producer anywhere, orphaned by
f812db8d1. So there was one command only macOS could reach and one only WPE could serve.

Consolidated onto WebKitExecuteScript in the shape of the neighbouring WebKitLoadUri arm;
WebKitExecuteJavaScript deleted. A build with neither backend now warns that the script was
dropped instead of logging that it ran.

One caveat worth stating: wpe-webkit is not a default feature and no CI workflow builds
with it, so the new WPE arm is not compile-checked upstream. It is a copy of an arm that is,
and it is being verified on a real x86_64-linux machine — where xwidget-webkit-scroll-*
should go from no-op to working, which is the concrete gain this change makes for Linux.

4. Documentation

c_features.rs and provide_coupled_vars.rs both said the browse-url path "is complete".
Corrected: what is true is that it works on the primary frame, which is the question a
configuration gating on (featurep 'xwidget-internal) is asking. Both now enumerate the gaps.
docs/building.md carries the user-facing version.

Issues opened: #300 (tracking, your six follow-ups), #301 and #302 (the two
maintainer notes above).

Verification

  • 791 display-runtime tests pass; cargo fmt --check and clippy clean on new code.
  • Feature and coupled-vars ledgers pass. The single remaining neovm-core failure is the
    pre-existing macOS one, the_features_this_build_really_has_still_answer_t, which asserts
    (featurep 'inotify)left: "OK (t t t t t nil)". Unchanged by this branch.
  • Release build, real page in a live WKWebView, then set-window-vscroll to force
    clip_top > 0 — the exact case in fix 1. The visible band is correct and flush with the
    text-area top.
  • Linux is untouched: xwidget-internal still answers NotBuilt, both ledger tests keep
    their pre-branch answers, and the only Linux-visible change is execute-script starting to
    work on a WPE build.

@eval-exec
eval-exec requested review from eval-exec and a balanced review from Copilot August 29, 2026 17:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It adds a substantial new macOS-only native-FFI backend (unsafe NSView/WKWebView handling, main-thread and coordinate-flip assumptions, ledger changes) that is not exercised by the primary CI, so it warrants human review beyond the one dead-field issue found.

Pull request overview

This PR implements an inline browser for macOS xwidgets using the system WKWebView as a native NSView overlay positioned over the GPU surface (since WKWebView cannot render offscreen), rather than the Linux WPE/dma-buf texture path. It ports GNU Emacs' xwidget.c/nsxwidget.m placement algorithm function-for-function, adds two missing C subrs, and flips xwidget-internal to be present on macOS via a build.rs probe. It closes the macOS half of #22.

Changes:

  • New backend/wkwebview module: WkWebViewHost (view lifecycle, per-frame sync/hide) and WkWebView/Placement (Emacs-ported clip/flip geometry), wired into the render thread after present.
  • Two new subrs (xwidget-webkit-execute-script, xwidget-webkit-estimated-load-progress) plus the WebKitExecuteScript asset command and host plumbing; FUN callback signals as unsupported and load progress is advisory (0.0/1.0).
  • neomacs_have_wkwebview build probe makes xwidget-internal provided on macOS; feature/coupled-vars/subr-surface ledgers made platform-aware (Linux unchanged); new deps and docs.
File summaries
File Description
backend/wkwebview/mod.rs New WkWebViewHost: attach, create/load/execute/resize/destroy, per-frame sync_frame with touch/hide and one-view-per-model guard.
backend/wkwebview/view.rs New Placement geometry (clip/flip) and WkWebView AppKit view pair; contains the unread model_* fields flagged below.
backend/wkwebview/view_test.rs 12 geometry tests pinning the ported Emacs arithmetic and the bottom-up reclip regression.
render_thread/render_pass.rs sync_inline_web_views sweep invoked after present for the primary frame.
render_thread/state.rs Adds wkwebview_host field, initialized from WkWebViewHost::new().
render_thread/asset_commands.rs Handles WebKit create/load/execute/resize/destroy on macOS.
thread_comm.rs New AssetCommand::WebKitExecuteScript variant.
xwidget.rs New subrs + load_progress runtime state; goto-uri sets progress to 1.0.
display_host.rs / neomacs-bin/src/main.rs New execute_webkit_xwidget_script host method + command dispatch.
c_features.rs / provide_coupled_vars.rs (+ tests) xwidget-internal now DetectedAtBuildTime; ledgers made platform-aware.
gnu_subr_surface_test.rs Registers the two new subrs as GNU-declared-in-this-branch.
build.rs detect_wkwebview emits neomacs_have_wkwebview on macOS.
builtins/mod.rs Registers the two new subrs.
Cargo.toml / Cargo.lock / docs/building.md objc2/WebKit deps and macOS inline-browser documentation.
Review details
  • Files reviewed: 21/22 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread neomacs-display-runtime/src/backend/wkwebview/view.rs Outdated
@tag-und-nacht

tag-und-nacht commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

@eval-exec I have checked MathJax is rendering flawlessly with a very heavy-math LaTeX (inline and display) markdown document:

image

Preview sync is working just as it was for Vanilla Emacs (I move in the markdown document, and preview moves along).

My own tests pass with this changes. E.g.

  1. my-markdown-xwidget-config-test.el
  2. my-markdown-xwidget-scroll-sync-test.el

@tag-und-nacht

Copy link
Copy Markdown
Contributor Author

@eval-exec Mermaid diagrams are rendered OK, and the zoom (in/out/fit) and pan features work as well. The following screenshot also exercises the styling (light theme now):

image

The following screenshot shows the zoom controls (already zoomed in):

image

@tag-und-nacht

Copy link
Copy Markdown
Contributor Author

@eval-exec

Copy link
Copy Markdown
Owner

claude.ai/code/artifact/a85a4e48-acff-4d94-b2d8-c22c8a5ee652

Thank you for the pr, this link is broken:
image

@tag-und-nacht

tag-und-nacht commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

claude.ai/code/artifact/a85a4e48-acff-4d94-b2d8-c22c8a5ee652

Thank you for the pr, this link is broken

Sorry, I forgot to change the sharing permissions 😅
Can you try again, please? It should work now.

@eval-exec

Copy link
Copy Markdown
Owner

claude.ai/code/artifact/a85a4e48-acff-4d94-b2d8-c22c8a5ee652

Thank you for the pr, this link is broken

Sorry, I forgot to change the sharing permissions 😅 Can you try again, please? It should work now.

The link works now.

@eval-exec

Copy link
Copy Markdown
Owner

The native WKWebView overlay is the right macOS architecture, and I think this PR can be merged incrementally without first completing the full multi-frame/event/focus design. The real-world MathJax, Mermaid, and JavaScript verification is valuable.

Before merging, I think three concrete correctness issues should be fixed:

  1. The WKWebView is positioned using the wrong coordinate orientation inside its clipping view.

    host_flipped is correct for positioning clip.frame, because that frame is expressed in the host’s coordinate system. But web.frame is expressed in the clip view’s coordinate system.

    winit’s host view is flipped, while clip is created as a plain NSView, which defaults to unflipped. Passing host_flipped to inner_origin therefore assumes incorrectly that the clip inherits its parent’s orientation.

    This is distinct from the pure-reclip case already handled by the PR. For height=100, clip_top=20, and visible_height=80, an unflipped clip needs an inner Y origin of 80 + 20 - 100 = 0; the current flipped formula produces -20 and clips the opposite vertical edge.

    Please use an explicitly flipped clip-view subclass, as GNU Emacs does, or calculate the host and clip orientations independently.

  2. Commands can be lost before the native host is attached.

    create retains a pending view, but a subsequent LoadUri, resize, or script command sees an unknown view and is discarded. A valid Create → LoadUri sequence sent before the first native window is attached can therefore create an empty WKWebView.

    Please retain these operations until the pending view becomes live, or otherwise guarantee that creation and attachment complete before later commands are accepted.

  3. The new execute-script command silently does nothing on Linux.

    WebKitExecuteScript executes on macOS, but its non-macOS path consumes the script without forwarding it to WPE. Please consolidate it with the existing WebKitExecuteJavaScript path or implement it for both backends, so adding macOS support does not create a false implementation on Linux.

I am comfortable handling the following as tracked follow-ups rather than blocking this PR:

  • support WKWebViews in secondary top-level frames;
  • actual WKNavigationDelegate/KVO events and measured load progress;
  • JavaScript result callbacks;
  • keyboard-focus handoff;
  • consolidating lifecycle ownership into a frame-aware runtime module;
  • avoiding the per-frame glyph scan and temporary placement allocation.

Please document that the initial macOS xwidget-internal support is partial—especially the primary-frame restriction, approximate load progress, missing script callbacks, and incomplete keyboard focus—and open follow-up issues for them. It should not yet be described as complete GNU Emacs xwidget compatibility.

With the three correctness fixes above, I support merging the native WKWebView implementation and improving the remaining behavior incrementally.

@eval-exec eval-exec added this to the v0.0.17 milestone Aug 30, 2026
tag-und-nacht added a commit to tag-und-nacht/neomacs that referenced this pull request Aug 30, 2026
Review catch (PR eval-exec#297). `Placement::inner_origin' positioned the WKWebView
inside the clip view using `host_flipped' -- the *host's* orientation applied
to a frame expressed in the *clip's* coordinate system. The clip was a stock
NSView, which is bottom-left, always; the host is winit's content view, which
is winit's business.

GNU settles this by making the views match the arithmetic rather than the
other way round. src/nsxwidget.m defines two flipped NSView subclasses --
XwWindow at :483 and XvWindow at :553, both `isFlipped { return YES; }' -- and
EmacsView is flipped too (nsterm.m:8540). With all three flipped,
src/xwidget.c:2993-2995 writes the inner origin unconditionally:

  nsxwidget_resize_view (xv, clip_right - clip_left, clip_bottom - clip_top);
  nsxwidget_move_widget_in_view (xv, -clip_left, -clip_top);

So: XwidgetClipView, the port's first objc2 subclass, overriding isFlipped.
`inner_origin' loses its parameter and becomes Emacs' formula unchanged.

The reviewer's numbers, for the record: height 100, clip_top 20, visible
height 80. The correct inner Y in a flipped clip is -20. The old bottom-up
branch answered `visible_height + clip_top - height' = 0, pinning the widget's
top edge where its bottom belongs. Whether that fired depended on what
winit's content view reports for isFlipped, which nothing here had measured --
so `attach' now logs it.

`ns_origin' keeps `host_flipped', because that frame really is in the host's
space. Its gate is extracted into `needs_reposition' so the rule is testable
without AppKit, and it grows a third case found while mapping this: the
bottom-up origin also folds in the host's *own* height, which no placement
diff can see, so a window resized under a widget that did not itself move
left the view at a stale origin. `WkWebView' now remembers the host geometry
it last placed against.

Verified: the two new geometry rules are red under the old code -- old gate
answers false where the test asserts true, old inner_origin(false) answers 0
where it must answer -20. 791 display-runtime tests pass.
tag-und-nacht added a commit to tag-und-nacht/neomacs that referenced this pull request Aug 30, 2026
Review catch (PR eval-exec#297). `WkWebViewHost' buffered a create that arrived before
the native host view did -- but only the create, and only its size:

    pending: HashMap<u32, (f64, f64)>,

`attach' replayed that map and nothing else. Every other entry point missed
`views.get(&id)' and dropped the command with a warn. So a `make-xwidget'
followed by `xwidget-webkit-goto-uri', both evaluated before the primary frame
was realized, built a blank WKWebView that never navigated -- while Lisp
recorded the URI and set load progress to 1.0, so `xwidget-webkit-uri' said
otherwise. A resize in the same window was worse: it was dropped *and* the
view was then built at the stale create size.

This is reachable rather than theoretical. The primary frame starts
`FrameLifecycle::Pending', and the render thread drains its command channel
unconditionally, independent of frame presentation, so anything sent in that
interval is gone before a view could exist for it.

Two halves:

- `pending' now holds the commands, not the sizes: one arrival-ordered FIFO
  for every id, drained through the ordinary methods by `attach'. One queue
  rather than one per id, because ordering matters across ids as well as
  within one, and a single vector gets that for free. `destroy' drops an id's
  queued work. At capacity the newest is dropped, not the oldest, so what
  survives is a coherent prefix rather than a set of orphaned loads whose
  create was evicted.

- Every WebKit command now tries `attach', not just `WebKitCreate'. A window
  that becomes available *between* a create and the load behind it was
  previously picked up only at the next present, by which time the load had
  already been drained and discarded.

The queue is its own type with no AppKit in it, so it is unit-testable off the
main thread -- which the host itself is not, `WkWebView::new' being a WKWebView
constructor. Eight tests, red by construction: the old structure had nowhere
to put a URL.
tag-und-nacht added a commit to tag-und-nacht/neomacs that referenced this pull request Aug 30, 2026
Review catch (PR eval-exec#297). `AssetCommand::WebKitExecuteScript' ran the script on
macOS and, on every other platform, did this:

    #[cfg(not(target_os = "macos"))]
    let _ = script;

with a `debug!' above it announcing that the script had been executed. So
`xwidget-webkit-execute-script' was a false implementation on Linux: silently
nothing, cheerfully logged.

The tree already had the missing half, orphaned. `WebKitExecuteJavaScript'
carried an identical payload and a real WPE dispatch, and had no producer
anywhere -- left behind by f812db8, the emacs-c bridge removal. One command
that worked only on macOS, one that worked only on WPE, and no way for either
to be reached from both.

Consolidated onto `WebKitExecuteScript', in the shape of the neighbouring
`WebKitLoadUri' arm that already compiles under both cfgs: WKWebView on macOS,
`webkit_web_view_evaluate_javascript' on WPE, and -- for a build with neither
backend -- a warning that says the script was dropped instead of a debug line
claiming it ran. `WebKitExecuteJavaScript' is deleted; its only other
reference was one construct-and-destructure test, repointed.

This is a fix for Linux, not a tidy-up. `lisp/xwidget.el' routes scrolling,
zoom and element navigation through this subr, and `lisp/net/shr.el' uses it
to install the video element for inline video. All of them were no-ops on a
`--features wpe-webkit' build.

Note for CI: `wpe-webkit' is not a default feature and no workflow builds
with it, so the new WPE arm is not compile-checked upstream. It is a copy of
an arm that is.
tag-und-nacht added a commit to tag-und-nacht/neomacs that referenced this pull request Aug 30, 2026
Review ask (PR eval-exec#297): document that the initial macOS `xwidget-internal'
support is partial, and do not describe it as complete GNU Emacs xwidget
compatibility.

Two places in the tree overclaimed. `c_features.rs' said the browse-url path
"is complete"; `provide_coupled_vars.rs' repeated it. What is true is
narrower: `xwidget-webkit-browse-url' works end to end on the primary frame,
and that is enough for the flag to be worth setting, because `xwidget.el' does
not require the feature -- the flag decides whether a configuration reaches
for the layer at all.

Both now enumerate the gaps and point at the tracking issue. `docs/building.md'
gains the user-facing version of the same list, so someone meets these before
filing a bug:

- primary frame only; a second top-level frame gets no view
- load progress is dispatched, not measured -- no WKNavigationDelegate or KVO,
  so a script run immediately after opening a page can precede the page
- no script result callbacks; `xwidget-webkit-execute-script' signals on FUN,
  which is why `xwidget-webkit-get-selection' and
  `xwidget-webkit-insert-string' do not work
- keyboard focus is not handed off in either direction; mouse works

Tracked in issue 300. Two findings that are independent of this branch were
split out rather than left to vanish with the PR description: an oversized
xwidget vanishing where GNU crops it (issue 301), and the WPE draw path in
content.rs discarding clip_rect where its sibling in layer_media.rs honours it
(issue 302).

Linux is untouched by all of it: `xwidget-internal' still answers NotBuilt
there and ledger 190's missing subrs are still open.
@tag-und-nacht
tag-und-nacht force-pushed the feature/xwidget-macos branch from 257256f to f4a7f6e Compare August 30, 2026 14:32
@tag-und-nacht

Copy link
Copy Markdown
Contributor Author

Thank you — all three were real, and none of them was found by any test I had written. Rebased onto main (57 commits, clean) and pushed four commits on top rather than squashing, so the fixes read as a diff against what you reviewed. Point by point:

1. Clip-view orientation — 5ef37c7ec. Correct, and it was worse than latent, in the opposite direction from what I would have guessed. I had never actually read winit's answer, so attach now logs it once: isFlipped = true. Which means the old code took the host_flipped branch and wrote -clip_top — the right number for a flipped clip — into a stock NSView that was not one. A widget scrolled under the window's top edge was misplaced inside its own clip by exactly height - visible_height, on every macOS build, every time.

Fixed structurally rather than arithmetically, as you suggested: XwidgetClipView, a flipped NSView subclass matching XvWindow (nsxwidget.m:553), so inner_origin reduces to xwidget.c:2995 unchanged. ns_origin keeps host_flipped, since that frame genuinely is in the host's space.

Rewriting its gate turned up a third input nothing was watching: the bottom-up origin also folds in the host's own height, which no placement diff can see, so a window resized under an unmoved widget left a stale origin. The rule is now needs_reposition(moved, reclipped, host_changed, host_flipped), testable without AppKit. Both new rules are red under the old code — the old gate answers false where the test asserts true, and the old inner_origin(false) answers 0 where your worked example requires -20.

2. Commands lost before attachment — d150626cb. Correct, and there was a second half I would not have found without your framing. Only WebKitCreate ever called attach, so even a window that became available between a create and the load behind it was picked up at the next present — by which time poll_commands, which drains unconditionally and independently of presentation, had already discarded the load.

pending now holds the commands rather than the create's size: one arrival-ordered FIFO for all ids, drained by attach through the ordinary methods, following the pending_child_frames idiom already in the render thread. One queue rather than one per id, because ordering matters across ids too. destroy drops an id's queued work; at capacity the newest is dropped, so what survives is a coherent prefix rather than loads whose create was evicted. And every WebKit arm now tries attach. The queue is its own type with no AppKit in it, so it is unit-testable off the main thread.

3. execute-script on Linux — d78bd3ffa. Correct, and I found the missing half already in the tree: WebKitExecuteJavaScript carried an identical payload and a real WPE dispatch with no producer anywhere, orphaned by f812db8d1. So there was one command only macOS could reach and one only WPE could serve. Consolidated onto WebKitExecuteScript in the shape of the neighbouring WebKitLoadUri arm; the dead variant is deleted. A build with neither backend now warns that the script was dropped rather than logging that it ran.

One caveat you should know about: wpe-webkit is not a default feature and no CI workflow builds with it, so the new WPE arm is not compile-checked upstream. It is a copy of an arm that is, and I am verifying it on a real x86_64-linux machine — where xwidget-webkit-scroll-forward and friends should go from no-op to working, which is the concrete gain this makes for Linux.

4. Documentation. You were right that it overclaimed. c_features.rs and provide_coupled_vars.rs both said the browse-url path "is complete"; both now say what is actually true — it works on the primary frame, which is the question a configuration gating on (featurep 'xwidget-internal) is asking — and enumerate the gaps. docs/building.md carries the user-facing version, so someone meets the limits before filing them as bugs. The PR description no longer describes this as complete xwidget compatibility, and no longer claims to close #22.

Issues opened:

Verification. 791 display-runtime tests, fmt and clippy clean on new code, both ledgers passing. The single remaining neovm-core failure is the pre-existing macOS one — the_features_this_build_really_has_still_answer_t asserting (featurep 'inotify), left: "OK (t t t t t nil)" — unchanged by this branch. End to end: a real page in a live WKWebView, then set-window-vscroll to force clip_top > 0, which is exactly the case fix 1 addresses; the visible band is correct and flush with the text-area top.

Linux is untouched throughout: xwidget-internal still answers NotBuilt, both ledger tests keep their pre-branch answers, and the only Linux-visible change is execute-script starting to work.

@eval-exec

Copy link
Copy Markdown
Owner

Thank you! Is this PR ready to merge?

@eval-exec

Copy link
Copy Markdown
Owner

Re-review of the latest commits: the three blockers from my earlier review are fixed.

I found one remaining before-merge issue:

P2: pending-queue overflow can still recreate a blank or missing web view

PendingCommands::push says it preserves a coherent prefix by dropping the newest command at capacity. That guarantee stops holding in two cases:

  1. With 255 entries queued, a new xwidget's Create can be accepted as entry 256 and its following LoadUri dropped. On attachment, this creates a blank WKWebView—the same externally visible failure this queue is intended to prevent.
  2. Once a command has been dropped, forget(id) can reduce the queue below capacity, after which later commands are accepted again. For example, a dropped Create(300) can be followed later by an accepted LoadUri(300), leaving an orphan command.

/// Defer one command.
///
/// At capacity the *newest* is dropped, not the oldest: what survives is
/// then a coherent prefix of the sequence, where evicting from the front
/// would drop a `Create` and leave every later command for that id
/// referring to a view that is never built.
pub(super) fn push(&mut self, command: PendingCommand) {
if self.queue.len() >= CAPACITY {
if !self.warned_full {
self.warned_full = true;
tracing::warn!(
"wkwebview: more than {CAPACITY} commands queued with no window to \
replay them into; dropping the rest"
);
}
return;
}
self.queue.push(command);
}
/// Drop everything queued for a killed xwidget.
pub(super) fn forget(&mut self, id: u32) {
self.queue.retain(|command| command.id() != id);
}

The current capacity test queues only independent Create commands, so it does not cover either lifecycle boundary:

/// A window that never arrives must not let this grow without bound, and what
/// is kept has to be the oldest commands: dropping from the front would evict
/// a `Create` and orphan everything queued after it.
#[test]
fn the_queue_is_capped_and_keeps_the_oldest() {
let mut pending = PendingCommands::new();
for id in 0..u32::try_from(CAPACITY + 10).expect("fits in u32") {
pending.push(create(id));
}
assert_eq!(pending.len(), CAPACITY);
let replayed = pending.take();
assert_eq!(replayed[0].id(), 0);
assert_eq!(
replayed[CAPACITY - 1].id(),
u32::try_from(CAPACITY - 1).expect("fits in u32")
);

My preferred fix is to make overflow atomic per xwidget: if any command for an ID is dropped, remove that ID's already-queued commands and reject subsequent commands for it until Destroy. Please add regression tests for both Create -> LoadUri at the capacity boundary and overflow -> forget -> later push.

One non-blocking design follow-up: the command taxonomy and routing are repeated across PendingCommand, replay dispatch, and asset-command host attachment. Centralizing the conversion/dispatch would make future command additions exhaustively checked rather than requiring several coordinated matches.

All platform cargo checks currently pass. The workflow-lint failure is from a pre-existing ShellCheck finding in scripts/test-macos-dependency-closure.sh, which this PR does not modify.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large macOS-only native AppKit backend with intricate coordinate geometry that the Linux CI does not compile or exercise, so final human review on macOS is warranted.

Review details
  • Files reviewed: 24/25 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +519 to +557
pub(crate) fn builtin_xwidget_webkit_execute_script(
eval: &mut Context,
args: Vec<Value>,
) -> EvalResult {
// GNU takes (XWIDGET SCRIPT &optional FUN) and feeds the script's return
// value to FUN. Delivering a result needs a channel from the render
// thread back to the Lisp thread, which does not exist yet, so FUN is
// accepted and reported as unsupported rather than silently ignored --
// a caller that passes it would otherwise wait forever for a call that
// never comes.
expect_args_range("xwidget-webkit-execute-script", &args, 2, 3)?;
let value = expect_live_webkit_xwidget(args[0])?;
let script = expect_string(args[1])?;
let id = value.as_xwidget().unwrap().xwidget_id;
if args.get(2).is_some_and(|fun| !fun.is_nil()) {
return Err(signal(
"error",
vec![Value::string(
"xwidget-webkit-execute-script: the FUN callback is not supported \
in this build; the script still runs when FUN is omitted",
)],
));
}
if let Some(host) = eval.display_host.as_ref() {
host.execute_webkit_xwidget_script(id, script)
.map_err(|err| signal("error", vec![Value::string(err)]))?;
}
Ok(Value::NIL)
}

pub(crate) fn builtin_xwidget_webkit_estimated_load_progress(
_eval: &mut Context,
args: Vec<Value>,
) -> EvalResult {
expect_args_range("xwidget-webkit-estimated-load-progress", &args, 1, 1)?;
let value = expect_live_webkit_xwidget(args[0])?;
let id = value.as_xwidget().unwrap().xwidget_id;
Ok(Value::make_float(_eval.xwidgets.webkit_load_progress(id)))
}
WKWebView has no offscreen render path — WebKit2's process architecture
rules it out, as GNU Emacs records in the header comment of
src/nsxwidget.m — so macOS cannot take the dma-buf texture route that
backend/wpe uses on Linux. It gets a native NSView subtree layered over
the GPU surface instead, which is what GNU Emacs has shipped for years.

The placement algorithm is ported from GNU Emacs function for function:

  produce_xwidget_glyph (xdisp.c)      -> GlyphType::Xwidget, already here
  x_draw_xwidget_glyph_string:2841-49  -> Placement::new
  xwidget.c:2856 / :2961               -> Placement::moved_from/reclipped_from
  xwidget.c:2888, 2995, 2996           -> WkWebView::apply
  xwidget_end_redisplay (xwidget.c)    -> WkWebViewHost::sync_frame
  nsxwidget_hide_view (nsxwidget.m)    -> WkWebView::hide

Most of the scaffolding already existed: the layout engine reserves the
slot, and FrameGlyph::Xwidget already carries a resolved rect and a
clip_rect. What was missing is the per-frame sweep that hides views the
frame no longer references, the two dirty checks, and the Cocoa backend.

The sweep runs from render_frame_window_impl after the present, which is
where Emacs calls xwidget_end_redisplay from dispnew.c:4626. It has to
run on every presented frame, including the retained-static fast path
that skips the glyph pipeline; hooking it into the renderer's draw walk
would strand views on exactly those frames. Views are hidden by default
unless a frame vouched for them, so scrolling away, switching buffers
and deleting a window all work without those paths knowing web views
exist.

Two places where the port could not be literal:

- Emacs' views are flipped (nsterm.m:8540, nsxwidget.m:554) so it
  positions them top-down. The winit content view carries no such
  guarantee, so geometry is computed top-down as Emacs computes it and
  converted per host in Placement::ns_origin/inner_origin. A bottom-up
  origin depends on the visible height, so unlike Emacs we must rewrite
  it on a pure reclip too — a window resized shorter under a widget
  whose top has not moved. Covered by a regression test.

- Emacs gets clipping from view nesting; we set clipsToBounds
  explicitly rather than rely on a platform default that has changed
  across macOS releases.

Scope: placement only. Mouse events reach the view through the responder
chain by virtue of it being a real subview, but keyboard focus handoff
is not addressed here.

Verified: cargo build -p neomacs links WebKit.framework; 777 lib tests
pass, 12 of them new geometry tests; clippy and rustfmt clean.
…ogress

The macOS WKWebView backend landed in the previous commit but the feature
flag still said NotBuilt, so configuration that gates on
`(featurep 'xwidget-internal)' skipped every xwidget module -- which is the
correct thing for such a config to do, and the reason the flag has to move
rather than the config.

Two subrs stood between the flag and the truth, both on the
`xwidget-webkit-browse-url' path:

- xwidget-webkit-execute-script, over WKWebView's
  evaluateJavaScript:completionHandler:. GNU's optional FUN receives the
  script's return value, which needs a result channel from the render
  thread back to the Lisp thread that does not exist here. FUN therefore
  signals rather than being silently dropped: a caller that passed it would
  otherwise wait forever for a call that never comes.

- xwidget-webkit-estimated-load-progress. Not measured, and documented as
  such: the web view lives on the render thread and nothing sends
  InputEvent::WebKitLoadFinished, so this is 0.0 before a navigation and
  1.0 once one is dispatched. A stuck 0.0 renders a permanent "[0%]" in
  xwidget-webkit-mode's header line, which is the worse lie.

The remaining names that `lisp/xwidget.el' reaches for turned out to be
Lisp functions defined in that file (xwidget-insert, xwidget-put,
xwidget-window-inside-pixel-width), not missing C subrs.

xwidget-internal is now decided by a build.rs probe, following the
neomacs_have_lcms2 pattern -- DetectedAtBuildTime requires citing a probe
and `cfg!(target_os)' is not one. Linux keeps its old answer: its WPE path
renders through dma-buf and ledger 190's missing subrs are still open
there. `xwidget.el' does not require the feature (its `(require
'xwidget-internal)' is commented out at line 32), so the flag is advisory
to configuration, which is exactly what reads it.

Two ledger tests become platform-aware, both unchanged on Linux: the
derived-feature order, and the coupled-vars absence probe -- whose own
docstring says firing is the designed signal that a build gained a
feature. XWIDGET_LAYER is rewritten because on macOS those three variables
are now bound *because* the feature is present, matching GNU, rather than
as a policy exception.

Verified end to end against a real configuration and a 168KB Obsidian
document with 170 math blocks: cap predicate t, zero skipped modules,
markdown-xwidget preview renders in a live WKWebView with MathJax typeset
(equations 1, 2, 3a/3b), and injected JavaScript both scrolls the page and
restyles it. Not fixed: with no load-finished event, a script run
immediately after enabling the preview is a no-op because the page has not
loaded yet; scroll-sync runs on an idle timer and is unaffected.

Pre-existing and untouched: c_features_test::the_features_this_build_
really_has_still_answer_t asserts (featurep 'inotify) and fails on any
macOS build. Confirmed by stashing this change and re-running.
Review catch: `model_width` / `model_height` were only ever written -- in
`new` and in `resize` -- and never read. `apply` sizes the inner web view
from `placement.width` / `placement.height`, and `resize` writes the frame
from its own arguments, so the fields carried no information.

Removing them rather than reading from them is deliberate. `Placement`
already carries the widget box precisely so the clip and the inner
web-view frame derive from one value; a second copy on the struct could
only drift out of step with it. The two agree by construction anyway:
`parse_display_xwidget_layout` builds the glyph's width/height straight
from the live xwidget object, and `xwidget-resize` mutates exactly those
fields, so a resize reaches the placement on the next frame.

No behaviour change.
Review catch (PR eval-exec#297). `Placement::inner_origin' positioned the WKWebView
inside the clip view using `host_flipped' -- the *host's* orientation applied
to a frame expressed in the *clip's* coordinate system. The clip was a stock
NSView, which is bottom-left, always; the host is winit's content view, which
is winit's business.

GNU settles this by making the views match the arithmetic rather than the
other way round. src/nsxwidget.m defines two flipped NSView subclasses --
XwWindow at :483 and XvWindow at :553, both `isFlipped { return YES; }' -- and
EmacsView is flipped too (nsterm.m:8540). With all three flipped,
src/xwidget.c:2993-2995 writes the inner origin unconditionally:

  nsxwidget_resize_view (xv, clip_right - clip_left, clip_bottom - clip_top);
  nsxwidget_move_widget_in_view (xv, -clip_left, -clip_top);

So: XwidgetClipView, the port's first objc2 subclass, overriding isFlipped.
`inner_origin' loses its parameter and becomes Emacs' formula unchanged.

The reviewer's numbers, for the record: height 100, clip_top 20, visible
height 80. The correct inner Y in a flipped clip is -20. The old bottom-up
branch answered `visible_height + clip_top - height' = 0, pinning the widget's
top edge where its bottom belongs. Whether that fired depended on what
winit's content view reports for isFlipped, which nothing here had measured --
so `attach' now logs it.

`ns_origin' keeps `host_flipped', because that frame really is in the host's
space. Its gate is extracted into `needs_reposition' so the rule is testable
without AppKit, and it grows a third case found while mapping this: the
bottom-up origin also folds in the host's *own* height, which no placement
diff can see, so a window resized under a widget that did not itself move
left the view at a stale origin. `WkWebView' now remembers the host geometry
it last placed against.

Verified: the two new geometry rules are red under the old code -- old gate
answers false where the test asserts true, old inner_origin(false) answers 0
where it must answer -20. 791 display-runtime tests pass.
Review catch (PR eval-exec#297). `WkWebViewHost' buffered a create that arrived before
the native host view did -- but only the create, and only its size:

    pending: HashMap<u32, (f64, f64)>,

`attach' replayed that map and nothing else. Every other entry point missed
`views.get(&id)' and dropped the command with a warn. So a `make-xwidget'
followed by `xwidget-webkit-goto-uri', both evaluated before the primary frame
was realized, built a blank WKWebView that never navigated -- while Lisp
recorded the URI and set load progress to 1.0, so `xwidget-webkit-uri' said
otherwise. A resize in the same window was worse: it was dropped *and* the
view was then built at the stale create size.

This is reachable rather than theoretical. The primary frame starts
`FrameLifecycle::Pending', and the render thread drains its command channel
unconditionally, independent of frame presentation, so anything sent in that
interval is gone before a view could exist for it.

Two halves:

- `pending' now holds the commands, not the sizes: one arrival-ordered FIFO
  for every id, drained through the ordinary methods by `attach'. One queue
  rather than one per id, because ordering matters across ids as well as
  within one, and a single vector gets that for free. `destroy' drops an id's
  queued work. At capacity the newest is dropped, not the oldest, so what
  survives is a coherent prefix rather than a set of orphaned loads whose
  create was evicted.

- Every WebKit command now tries `attach', not just `WebKitCreate'. A window
  that becomes available *between* a create and the load behind it was
  previously picked up only at the next present, by which time the load had
  already been drained and discarded.

The queue is its own type with no AppKit in it, so it is unit-testable off the
main thread -- which the host itself is not, `WkWebView::new' being a WKWebView
constructor. Eight tests, red by construction: the old structure had nowhere
to put a URL.
Review catch (PR eval-exec#297). `AssetCommand::WebKitExecuteScript' ran the script on
macOS and, on every other platform, did this:

    #[cfg(not(target_os = "macos"))]
    let _ = script;

with a `debug!' above it announcing that the script had been executed. So
`xwidget-webkit-execute-script' was a false implementation on Linux: silently
nothing, cheerfully logged.

The tree already had the missing half, orphaned. `WebKitExecuteJavaScript'
carried an identical payload and a real WPE dispatch, and had no producer
anywhere -- left behind by f812db8, the emacs-c bridge removal. One command
that worked only on macOS, one that worked only on WPE, and no way for either
to be reached from both.

Consolidated onto `WebKitExecuteScript', in the shape of the neighbouring
`WebKitLoadUri' arm that already compiles under both cfgs: WKWebView on macOS,
`webkit_web_view_evaluate_javascript' on WPE, and -- for a build with neither
backend -- a warning that says the script was dropped instead of a debug line
claiming it ran. `WebKitExecuteJavaScript' is deleted; its only other
reference was one construct-and-destructure test, repointed.

This is a fix for Linux, not a tidy-up. `lisp/xwidget.el' routes scrolling,
zoom and element navigation through this subr, and `lisp/net/shr.el' uses it
to install the video element for inline video. All of them were no-ops on a
`--features wpe-webkit' build.

Note for CI: `wpe-webkit' is not a default feature and no workflow builds
with it, so the new WPE arm is not compile-checked upstream. It is a copy of
an arm that is.
Review ask (PR eval-exec#297): document that the initial macOS `xwidget-internal'
support is partial, and do not describe it as complete GNU Emacs xwidget
compatibility.

Two places in the tree overclaimed. `c_features.rs' said the browse-url path
"is complete"; `provide_coupled_vars.rs' repeated it. What is true is
narrower: `xwidget-webkit-browse-url' works end to end on the primary frame,
and that is enough for the flag to be worth setting, because `xwidget.el' does
not require the feature -- the flag decides whether a configuration reaches
for the layer at all.

Both now enumerate the gaps and point at the tracking issue. `docs/building.md'
gains the user-facing version of the same list, so someone meets these before
filing a bug:

- primary frame only; a second top-level frame gets no view
- load progress is dispatched, not measured -- no WKNavigationDelegate or KVO,
  so a script run immediately after opening a page can precede the page
- no script result callbacks; `xwidget-webkit-execute-script' signals on FUN,
  which is why `xwidget-webkit-get-selection' and
  `xwidget-webkit-insert-string' do not work
- keyboard focus is not handed off in either direction; mouse works

Tracked in issue 300. Two findings that are independent of this branch were
split out rather than left to vanish with the PR description: an oversized
xwidget vanishing where GNU crops it (issue 301), and the WPE draw path in
content.rs discarding clip_rect where its sibling in layer_media.rs honours it
(issue 302).

Linux is untouched by all of it: `xwidget-internal' still answers NotBuilt
there and ledger 190's missing subrs are still open.
Re-review catch (PR eval-exec#297, P2). The queue's overflow rule -- drop the newest
command, keep the front -- was described as leaving "a coherent prefix". It
is coherent per command and not per xwidget, and the boundary is where it
fails:

1. With 255 entries queued, a new view's Create is accepted as entry 256 and
   the LoadUri behind it is dropped. On attach that replays into a blank
   WKWebView -- the exact failure this queue was added to prevent.

2. Once a command has been dropped, a later `forget' can bring the queue
   under capacity, after which commands for the dropped id are accepted
   again: a Create(300) that was refused followed by a LoadUri(300) that was
   not, leaving an orphan.

The capacity test queued only independent creates, so it saw neither.

Overflow is now atomic per id, as the reviewer preferred: when a command for
an id is refused, that id's already-queued commands are removed and the id
is rejected until Destroy. `forget' (Destroy) lifts the rejection so a fresh
lifecycle under the same id starts clean. The invariant the struct now
states is "every id in the queue is whole or absent".

Three regression tests, the first two red before this change: a load that
overflows takes its create with it; an overflowed id stays rejected after
room frees up; destroying it lifts the rejection.
Review follow-up (PR eval-exec#297, non-blocking): the WebKit command shape was
spelled three times -- `PendingCommand', the `replay' match in the host, and
five `#[cfg(target_os = "macos")]' blocks in asset_commands.rs each doing
attach-then-call -- plus four copies of "if there is no host yet, queue it
and return" inside the host's per-command methods. Adding a command meant
touching every one of them, and nothing checked that you had.

Now `backend/wkwebview/command.rs' owns the taxonomy: `WebKitViewCommand',
with the one conversion from `AssetCommand'. The host has one entry point,
`dispatch', which either defers (Destroy is applied to the queue itself, so
a killed xwidget is never built) or hands to `apply_live', whose `match' is
the single exhaustive one -- a new variant fails to compile there. `attach'
replays through `dispatch'. asset_commands.rs intercepts once, at the top of
`handle_asset', and the per-arm macOS blocks are gone; the WPE arms are
untouched.

The conversion borrows rather than consumes so the same `AssetCommand' still
reaches the WPE arms on a build with both backends; the payload is an id and
at most one short string.

Two conversion tests; the eleven queue tests pass unchanged under the
rename. 796 display-runtime tests.
Review thread on PR eval-exec#297: the two subrs added for macOS had distinctive,
cheaply testable behaviour and no coverage, while their siblings are pinned
through the recording display host in xwidget_test.rs.

- `xwidget-webkit-estimated-load-progress' answers 0.0 after `make-xwidget'
  and 1.0 after `xwidget-webkit-goto-uri': dispatched, not measured. A future
  measured implementation now has to change this test on purpose.

- `xwidget-webkit-execute-script' signals on a non-nil FUN -- there is no
  result channel back to Lisp, and a callback that never fires is worse than
  an error -- and without FUN hands exactly one script to the host. The
  recording host gains an `ExecuteScript' event for this.

Both are load-bearing: with the progress bump removed and the FUN check
disabled, each fails.
@tag-und-nacht
tag-und-nacht force-pushed the feature/xwidget-macos branch from f4a7f6e to 03faba4 Compare August 30, 2026 15:32
@tag-und-nacht

Copy link
Copy Markdown
Contributor Author

Thanks — both P2 cases were real, and the second is the one I would not have found: I had reasoned about the overflow rule per command when the unit that matters is the xwidget. Rebased onto main (7 commits, clean) and pushed three commits on top.

P2 — overflow is now atomic per xwidget: b05266e0b. Your preferred fix. Refusing a command for an id evicts that id's already-queued commands and rejects the id until Destroy, which lifts the rejection so a fresh lifecycle under the same id starts clean. The invariant the struct now states is every id in the queue is whole or absent, replacing the "coherent prefix" claim that only held per command.

Regression tests for exactly the two boundaries you named, red before the fix and green after:

  • a_load_that_overflows_takes_its_create_with_it — 255 queued, Create(N) accepted as entry 256, LoadUri(N) overflows → no command for N is replayed.
  • an_overflowed_id_stays_rejected_after_room_frees_upCreate(300) refused, forget(0) frees a slot, LoadUri(300) → not accepted.
  • plus destroying_an_overflowed_id_lifts_its_rejection.

Design follow-up — one taxonomy, one dispatch: 25c730b80. backend/wkwebview/command.rs now owns WebKitViewCommand (the five WebKit commands including Destroy) and the single conversion from AssetCommand. WkWebViewHost has one entry point, dispatch, which defers or hands to apply_live — whose match is the one exhaustive site, so a new variant fails to compile there. attach replays through dispatch. asset_commands.rs intercepts once at the top of handle_asset; the five per-arm macOS blocks are gone and the WPE arms are untouched. The conversion borrows so the same AssetCommand still reaches the WPE arms on a dual-backend build.

Copilot's thread — 03faba433. Both subrs are now pinned through the recording host: xwidget-webkit-estimated-load-progress answers 0.0 then 1.0 across a goto-uri, and xwidget-webkit-execute-script signals on FUN and hands exactly one script to the host without it. I confirmed both are load-bearing by removing the behaviour and watching each fail.

Verification. 796 display-runtime tests; fmt, clippy on new code, and cargo check --workspace clean; the two ledgers pass with the single pre-existing inotify failure unchanged. Release build, then an xwidget-webkit-browse-url from init — before the first frame — goes through the new dispatch path with created view 1 and no unknown view or dropping lines in the log.

Thanks also for running the --features wpe-webkit check on Linux; that covers the arm CI does not build.

@tag-und-nacht

Copy link
Copy Markdown
Contributor Author

@eval-exec Report updated: https://claude.ai/code/artifact/a85a4e48-acff-4d94-b2d8-c22c8a5ee652

@eval-exec
eval-exec requested a balanced review from Copilot August 30, 2026 15:41

@eval-exec eval-exec left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@eval-exec

Copy link
Copy Markdown
Owner

Re-review of the three new commits at 03faba433.

The requested overflow-boundary tests now pass, and the centralized dispatch is a good direction. However, I found two before-merge lifecycle/state issues.

P2: Destroy can replay the xwidget it is supposed to cancel

Every native WebKit command now calls attach() before dispatch():

fn dispatch_to_wkwebview(&mut self, command: WebKitViewCommand) {
let window = self
.frame_windows
.primary_window_mut()
.and_then(|ws| ws.window().cloned());
let Some(host) = self.wkwebview_host.as_mut() else {
tracing::warn!(
"wkwebview: render loop is not on the main thread; dropping command for view {}",
command.id()
);
return;
};
if let Some(window) = window {
host.attach(&*window);
}
host.dispatch(command);

If the primary window becomes available immediately before a pending xwidget's Destroy, attach() binds the host and replays its queued Create and LoadUri first:

self.host = Some(ns_view);
for command in self.pending.take() {
self.dispatch(command);
}
}
/// The single entry point for every WebKit command.
///
/// Without a host the command is deferred -- except `Destroy`, which is
/// applied to the queue itself so a killed xwidget is never built. With a
/// host it is applied to the live view.
pub(crate) fn dispatch(&mut self, command: WebKitViewCommand) {
if self.host.is_none() {
match command {
WebKitViewCommand::Destroy { id } => self.pending.forget(id),
other => self.pending.push(other),
}
return;
}
self.apply_live(command);

Only after that replay does the current Destroy reach dispatch. The killed xwidget is therefore briefly constructed and can begin its network request before being removed. This contradicts dispatch's stated guarantee that a killed pending xwidget is never built.

Destroy should dispatch without attempting attachment: with no host it should only forget pending/rejected state; with an already-bound host it should remove the live view. Please add a regression test for:

pending Create -> pending LoadUri -> window becomes available -> Destroy

where neither create nor navigation is replayed.

P2: overflow state remains unbounded

The command vector is capped at 256 because a host may never arrive, but every subsequent unique xwidget ID is inserted into the uncapped rejected: BTreeSet<u32>:

pub(super) struct PendingCommands {
queue: Vec<WebKitViewCommand>,
/// Ids that lost a command to overflow. Nothing further is accepted for
/// them until `Destroy`, because a later `LoadUri` for a `Create` that was
/// never queued would be an orphan.
rejected: BTreeSet<u32>,
}
impl PendingCommands {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) fn is_empty(&self) -> bool {
self.queue.is_empty()
}
/// Defer one command.
///
/// At capacity the arriving command's whole id is dropped -- its earlier
/// queued commands too -- and the id is refused until `Destroy`. Dropping
/// only the newest command looked sufficient (the oldest are the creates,
/// so the front is the part worth keeping) but is not atomic at an id
/// boundary: a `Create` accepted as the last entry with its `LoadUri`
/// dropped replays into exactly the blank view being guarded against.
pub(super) fn push(&mut self, command: WebKitViewCommand) {
let id = command.id();
if self.rejected.contains(&id) {
return;
}
if self.queue.len() >= CAPACITY {
tracing::warn!(
"wkwebview: more than {CAPACITY} commands queued with no window to \
replay them into; dropping xwidget {id} until it is killed"
);
self.queue.retain(|queued| queued.id() != id);
self.rejected.insert(id);
return;

After 256 queued creates, a stream of fresh IDs grows rejected indefinitely and emits one warning per ID. That defeats the capacity guard's bounded-memory purpose and permits log flooding. Please make all retained overflow state bounded—for example, enter a global saturated mode once the cap is reached—or otherwise cap/tombstone rejected lifecycles without allowing partial replay. Add a test that pushes many unique IDs beyond CAPACITY and asserts total retained state remains bounded.

Additional follow-ups

  • Rejection is enforced only while commands are pending. Once host exists, dispatch() bypasses PendingCommands and a rejected ID can receive another Create before Destroy. The “rejected until Destroy” lifecycle state should be enforced at host level across attachment.
  • The new conversion is not actually exhaustive over AssetCommand: _ => return None means a future AssetCommand::WebKit* variant can compile without being added to the native backend, and the manually enumerated test will still pass:
    /// The WebKit command inside an asset command, if it is one.
    ///
    /// Borrows rather than consumes so the caller can still hand the same
    /// `AssetCommand` to the WPE arms; the payloads are an id and at most one
    /// short string, so the clone is not worth avoiding.
    pub(crate) fn from_asset(command: &AssetCommand) -> Option<Self> {
    Some(match *command {
    AssetCommand::WebKitCreate { id, width, height } => Self::Create {
    id,
    width: f64::from(width),
    height: f64::from(height),
    },
    AssetCommand::WebKitLoadUri { id, ref url } => Self::LoadUri {
    id,
    url: url.clone(),
    },
    AssetCommand::WebKitResize { id, width, height } => Self::Resize {
    id,
    width: f64::from(width),
    height: f64::from(height),
    },
    AssetCommand::WebKitExecuteScript { id, ref script } => Self::ExecuteScript {
    id,
    script: script.clone(),
    },
    AssetCommand::WebKitDestroy { id } => Self::Destroy { id },
    _ => return None,
    })

    A shared nested WebKit command enum would provide the intended compile-time guarantee.
  • from_asset(&cmd) clones every URL/script before the original command is consumed. JavaScript and data URLs can be large, so the “short string” assumption is unsafe for render-thread cost. An owned shared command type would eliminate this copy.
  • The WebKitReload conversion test calls it a “non-webkit command”; it is a WebKit command unsupported by the native backend. Renaming the test/comment would make the boundary clearer.

Verification at this head:

  • macOS cargo check passes.
  • cargo check -p neomacs-display-runtime --features wpe-webkit passes locally.
  • The three targeted xwidget nextest tests pass.

I would hold merge for the Destroy replay race and bounded overflow-state fixes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large, entirely macOS-only native WKWebView backend plus feature-flag and ledger changes that the Linux-based CI does not compile or exercise, so final human review on macOS is warranted despite the strong test coverage of the platform-agnostic logic.

Review details
  • Files reviewed: 27/28 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

…ys bounded

Re-review catch (PR eval-exec#297, round three), two P2s in the pending queue.

1. Every WebKit command called `attach' before `dispatch'. If the window
   became available just before a pending xwidget's Destroy, attach bound
   the host and replayed that xwidget's queued Create and LoadUri first --
   a view briefly built, possibly already fetching, then torn down. That
   contradicted `dispatch''s own claim that a killed pending xwidget is
   never built.

2. The command queue was capped at 256, but every id refused at overflow
   went into an uncapped `rejected' set with one warning each. A stream of
   fresh ids after saturation grew memory and the log without bound. And a
   subtler door the first test wrote for this found: evictions free slots,
   the queue refills, and refill-then-evict adds one refused id per
   xwidget -- unbounded again.

Both are decided in a new `Lifecycle' type that owns no AppKit at all. It
takes a command plus "is a window available" and returns Actions -- Bind,
Apply(command) -- which the host executes. Destroy never yields Bind: it
forgets the id's queued work and, if already bound, applies to the live
view. Overflow is a latch: once the queue has been full nothing more is
queued until a window arrives, refusals are tracked only for ids that had
something queued, and the warning fires once. Refusal now lives at
lifecycle level, so it survives binding: a refused id cannot get a fresh
Create post-attach until its Destroy.

Because the decisions are pure, the reviewer's sequences are unit tests:
pending Create -> pending LoadUri -> window becomes available -> Destroy
yields no Bind and no replay; a flood of 10*CAPACITY fresh ids with
interleaved evictions and destroys keeps retained state under CAPACITY.
The first version of the latch failed that second test -- it allowed
refills -- which is why the latch is total.

Also from the review: `from_asset' now consumes the AssetCommand instead of
cloning its strings (scripts and data: URLs can be large), returning a
command this backend lacks for the WPE arms; and the conversion test names
`WebKitReload' as what it is -- a WebKit command the native backend does
not implement, not a non-WebKit one.

Not done here, filed in issue 300: a nested AssetCommand::WebKit(_) so the
conversion itself is compiler-checked; today a new WebKit* variant can
compile without reaching this backend.

799 display-runtime tests.
@eval-exec

Copy link
Copy Markdown
Owner

Re-review of commit b376919c8.

The previous blockers are fixed:

  • Destroy no longer binds or replays the xwidget it kills.
  • Queue plus rejection state remains bounded.
  • Tracked rejected IDs remain rejected across attachment until Destroy.
  • URLs and scripts are moved rather than cloned.
  • The WebKitReload terminology is corrected.
  • The compiler-exhaustive nested AssetCommand::WebKit(...) design is now explicitly tracked in [Tracking] macOS xwidget-internal: remaining gaps after #297 #300.

I found one new before-merge integration bug.

P1: a failed native bind permanently corrupts lifecycle state

Lifecycle::bind() marks the lifecycle bound and drains its pending queue before the native host has actually bound:

}
/// The host has a window and nothing else to do: bind now and replay.
/// Idempotent; the render pass calls this every presented frame.
pub(super) fn bind(&mut self) -> Vec<Action> {
if self.bound {
return Vec::new();
}
self.bound = true;
let mut actions = vec![Action::Bind];
actions.extend(
std::mem::take(&mut self.queue)
.into_iter()
.map(Action::Apply),
);
actions
}

Only afterward does WkWebViewHost::execute() attempt window_handle(), verify that it is AppKit, and retain the NSView:

fn execute(&mut self, actions: Vec<Action>, window: Option<&impl HasWindowHandle>) {
for action in actions {
match action {
Action::Bind => {
let Some(window) = window else {
// The lifecycle only emits Bind when told a window
// exists; a window that then yields no AppKit handle
// is a winit contract violation worth being loud about.
tracing::error!("wkwebview: asked to bind with no window");
return;
};
if !self.bind_to(window) {
return;
}
}
Action::Apply(command) => self.apply_live(command),
}
}
}
/// Retain the window's content view. False if the window has no AppKit
/// handle, in which case nothing can be applied this frame.
fn bind_to(&mut self, window: &impl HasWindowHandle) -> bool {
if self.host.is_some() {
return true;
}
let Ok(handle) = window.window_handle() else {
return false;
};
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
return false;
};
// SAFETY: winit hands out a live NSView pointer for the window, and we
// retain it for as long as the host lives.
let ns_view: Retained<NSView> = unsafe {
let ptr: NonNull<c_void> = appkit.ns_view;
Retained::retain(ptr.as_ptr().cast())
.expect("winit AppKit window handle carries a live NSView")
};
// The host's orientation is winit's to decide, not ours, and every
// bottom-up conversion in `view.rs` turns on it -- so record which one
// this build actually got rather than assuming either.
tracing::info!(
"wkwebview: bound to winit content view (isFlipped = {})",
ns_view.isFlipped()
);
self.host = Some(ns_view);
true

If either handle operation fails, execute() returns and drops all remaining Apply actions. However, the lifecycle remains bound = true and its queue is already empty. The consequences are:

  • all deferred Create/LoadUri work is permanently lost;
  • later attachment attempts do nothing because the lifecycle believes it is bound;
  • later loads/resizes/scripts target unknown views;
  • a later Create reaches apply_live() and panics at the host.expect(...), because the lifecycle is bound while WkWebViewHost::host is still None.

The lifecycle tests cannot expose this because window_available: true is modeled as guaranteed binding success, while the real operation is fallible.

Please make binding transactional. A clean shape would be:

  1. lifecycle emits a bind request without setting bound or draining the queue;
  2. the host attempts to retain the AppKit view;
  3. only on success does commit_bound() set the state and return the queued replay actions;
  4. on failure, pending state remains intact so a later frame can retry.

Please add a failed-bind → retry regression test. Ideally the interface should carry an explicit bind result rather than reducing “can retain an AppKit host” to window_available: bool; that boolean and WkWebViewHost::host are currently two sources of truth.

P2: rejected commands can still flood debug logs

Every later command for a rejected ID emits another debug!:

if self.rejected.contains(&id) {
tracing::debug!("wkwebview: refusing command for xwidget {id}, dropped at overflow");
return Vec::new();
}

The flood test proves memory remains bounded, but with debug logging enabled a single rejected ID can still generate unbounded log volume. Please remove the per-command message or latch it behind the single saturation warning, and cover the emission behavior in the flood test.

I would hold merge until native binding is transactional.

@tag-und-nacht

Copy link
Copy Markdown
Contributor Author

Both P2s fixed in b376919c8, and the second one taught me something on the way.

Destroy never binds. The decision of whether a command warrants binding to a window now lives in a new Lifecycle type (backend/wkwebview/lifecycle.rs) that owns no AppKit: it takes a command plus "is a window available" and returns actions — Bind, Apply(command) — which WkWebViewHost executes. Destroy never yields Bind; it forgets the id's queued work and, if already bound, applies to the live view. The router in asset_commands.rs no longer calls attach itself; it just offers the window.

Because the policy is pure, your sequence is a unit test verbatim — destroy_never_binds_and_never_replays_the_xwidget_it_kills: pending Create → pending LoadUri → window available → Destroy yields no actions at all, the lifecycle stays unbound, and a subsequent bind has nothing of that id to replay.

Overflow state is bounded. Saturation is now a latch, as you suggested: once the queue has been full, nothing more is queued until a window arrives, refusals are tracked only for ids that actually had something queued, and the warning fires once. Refusal also moved up to lifecycle level so it survives binding — your first follow-up: a refused id cannot get a fresh Create post-attach until its Destroy (a_refused_id_stays_refused_after_binding_until_destroy).

The bounded test (retained_state_stays_bounded_under_a_flood_of_fresh_ids, 10×CAPACITY fresh ids with interleaved evictions and destroys, asserting queue + refusals ≤ CAPACITY) failed against my first version of the latch, which still let the queue refill after a destroy or eviction. Refill-then-evict grows the refusal set by one id per xwidget — unbounded through a different door than the one you named. The latch is now total, and the commit message records that.

Also from the review: from_asset consumes the AssetCommand instead of cloning its strings, returning an unhandled command for the WPE arms; the WebKitReload test now says what it is — a WebKit command the native backend lacks. The nested AssetCommand::WebKit(_) for a compiler-checked conversion is a real gap and I've added it to #300 rather than widen this PR into main.rs and the WPE arms.

Verification. 799 display-runtime tests; fmt, clippy on new code, cargo check --workspace clean. GUI smoke with a fresh build, create → goto-uri → kill-xwidget from the init file followed by a browse-url: the log reads created view 1, destroyed view 1, created view 2, no unknown view or refusal lines. Worth being precise about what that does and does not show: with -l, winit's window already exists when the init file runs, so the first create bound immediately and the kill hit a live view — the pre-window Destroy race is not reachable from an init file on this machine, which is exactly why the sequence is pinned as a pure unit test rather than a GUI one.

@eval-exec

Copy link
Copy Markdown
Owner

Correction to my previous review: after checking the exact pinned winit 0.30.13 macOS implementation, I overclassified the transactional-binding concern.

On macOS, winit's raw_window_handle_rwh_06() returns HandleError::Unavailable only when there is no MainThreadMarker; on the main thread it returns an AppKit handle:

https://github.com/rust-windowing/winit/blob/v0.30.13/src/platform_impl/macos/window.rs#L49-L60

The AppKit handle is then constructed from winit's retained view, whose pointer is asserted non-null:

https://github.com/rust-windowing/winit/blob/v0.30.13/src/platform_impl/macos/window_delegate.rs#L1651-L1661

This PR already gates WkWebViewHost::new() on MainThreadMarker::new(), and the host cannot exist off the main thread:

impl WkWebViewHost {
/// Returns `None` off the main thread. Every AppKit call below assumes the
/// marker obtained here, so this is the single gate for the whole module.
pub fn new() -> Option<Self> {
let mtm = MainThreadMarker::new()?;
Some(Self {
mtm,
host: None,
views: HashMap::new(),
lifecycle: Lifecycle::new(),
})
}
/// Bind to a window's content view if not yet bound, and replay what was

Therefore, for the actual winit Window passed by Neomacs, the bind-failure branch I described is effectively unreachable: the host's existence proves the thread condition winit requires, and the resulting raw handle is AppKit with a non-null view.

Making the lifecycle/native bind transition transactional would still be a cleaner and more generally robust interface—especially because attach accepts a generic HasWindowHandle—but this is P3 design debt, not a production P1 and not a merge blocker.

Likewise, the per-rejected-command debug! can produce volume only after the pre-window queue has saturated and debug logging is enabled. It is worth removing or latching, but it is also nonblocking.

I withdraw my “hold merge” recommendation from the previous comment. From my review, this PR is mergeable once the remaining required CI checks succeed. Sorry for the severity overcall.

@eval-exec eval-exec left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @tag-und-nacht , I think this PR can be merged now.

Will merge after neomacs v0.0.16 released: https://github.com/eval-exec/neomacs/actions/runs/33319477117

@eval-exec
eval-exec merged commit ef23a59 into eval-exec:main Aug 30, 2026
18 of 19 checks passed
@tag-und-nacht
tag-und-nacht deleted the feature/xwidget-macos branch August 30, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants