Skip to content

feat(app): close upstream lifecycle, metadata, and content composition gaps - #2

Open
jinzhongjia wants to merge 36 commits into
mainfrom
rewrite/semantic-gaps
Open

jinzhongjia wants to merge 36 commits into
mainfrom
rewrite/semantic-gaps

Conversation

@jinzhongjia

@jinzhongjia jinzhongjia commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Closes several open semantic gaps recorded in docs/PURE_ZIG_REFACTOR.md against upstream WebUI (webui-dev/webui at f1b28ee).

Lifecycle

  • Running.wait() now follows upstream per-window semantics: a window that is closed on purpose ends immediately, an ordinary disconnect gets the 1.5 s reload grace period, and App.Options.startup_timeout (default 15 s, null waits forever) bounds the wait for the first connection to a shown window. Request activity extends that deadline, as upstream does.
  • Running.requestExit() closes every window and wakes the waiter. Concurrent waiters get error.AlreadyWaiting.
  • App.createWindow() also works while the app runs, like upstream webui_new_window. The new window gets fresh credentials, its folder, and its own folder monitor.
  • App.destroyWindow() mirrors webui_destroy. It unregisters the window at once, so its routes answer 404, connected pages get a backend close, and later messages close the transport with 1001. It then cancels the window's handlers, monitor, and managed browser in the background. The window is freed after its last connection, request, and deferred reply, and Running.stop() finishes any pending cleanup. It is safe to call from the window's own handlers, through the new Client.window().
  • Window state is now reference counted, and the window registry is guarded by a read-write lock. Each window has its own monitor task group, spawned with concurrent before the server starts.

Callback metadata

  • Call.name, Call.origin (.call or .click), and Call.cookies / Event.cookies with cookie(name) expose the binding name, the call origin, and a per-connection copy of the upgrade's Cookie header.
  • Cookie copies are bounded by Limits.max_cookie_size. Upgrades over the limit get 431.

Content and routing

  • Default favicon fallback: favicon.ico and favicon.svg are resolved from the custom icon first, then a readable directory file, then upstream's built-in SVG. favicon.ico answers 302 to favicon.svg. This applies at both the capability root and the origin root.
  • Content.site combines optional embedded HTML, a resource handler, a root folder, and an entry file. Requests resolve in upstream order: handler, then virtual-directory index probing, then root HTML, then folder files, then the default favicon or 404. A handler declines a path by answering an empty 404.
  • Site.entry makes the root redirect to a validated relative entry file, like webui_show(window, "page.html").
  • Plain .custom content now gets the same virtual-index probing.
  • Window.installContent() replaces resources without navigating clients. If either the current or the new content is an external URL, it returns error.NavigationRequired.

Firefox app mode

  • Firefox windows without a caller profile get a generated per-window profile, like Chromium windows. Before each launch it receives upstream's toolbar-hiding chrome/userChrome.css and a rewritten user.js. The user.js enables the stylesheet and turns off the default-browser check, the close warning, tabs in the title bar, and the first-run pages.
  • high_contrast = false now works for Firefox through the browser.display.document_color_use preference. A caller profile is never modified, so with one Firefox still returns error.UnsupportedBrowserHighContrast.
  • Snap Firefox cannot see the host /tmp. When /snap/bin/firefox exists, Firefox profiles move into the snap's user directory, as upstream does. The home directory comes from the passwd entry, because std.Io exposes no process environment.
  • managedProfileDirectory and the profile helpers now take an Io.

Browser discovery

  • Windows: Chrome and Chromium both install and register chrome.exe. Like upstream, a folder with Google's initial_preferences or master_preferences file is Google Chrome, and any other chrome.exe is Chromium. Registered Chromium is now found, and a Chromium chrome.exe is no longer reported as Chrome.
  • macOS: discovery also checks ~/Applications, then looks up the browser's bundle identifier through Spotlight (mdfind), which finds the bundle wherever it is installed. Upstream uses open -R -a, which reveals the app in Finder. mdfind has no side effects. A missing or disabled index just finds nothing.

Native page titles

  • Like upstream, each non-empty page title replaces the native host title. GTK listens to notify::title, WebView2 to DocumentTitleChanged, and Cocoa observes WKWebView.title through KVO. KVO also catches document.title changes after load, which upstream's didFinishNavigation misses. Options.title is the initial title, and setTitle applies at once until the page reports another one. An empty title keeps the host title, except that WebView2 reports its own default for untitled documents.
  • Options.follow_page_title / Window.setFollowPageTitle() keep the title under host control, and Window.title() reads the host title. The native smoke checks following, setTitle, empty titles, and a window with following turned off, on every platform.
  • The WebView2 close handler now uses a generic COM event handler that the title handler shares. @alignCast fixes the aarch64-windows native build.

Frameless drag and resize

  • WebKitGTK: like upstream, a document-start script finds the nearest --webui-app-region value. Once the primary button moves over a drag area, it posts on a dedicated message channel and the host starts a window-manager move. The host checks that the primary button is actually held, so a page cannot move the window by posting on its own. Resizable frameless windows resize from upstream's 6 px edge band, with matching resize cursors.
  • WebView2: enables ICoreWebView2Settings9 non-client regions, so CSS app-region: drag areas act as the caption (upstream PR #718). Runtimes without Settings9 report that drag regions are unavailable. Resizable frameless windows keep a WS_THICKFRAME sizing border.
  • Cocoa: frameless windows are movable by their background, as in upstream, across style and kiosk changes.
  • native.Window.dragRegion() reports the active model (.webui_property, .css_app_region, .window_background, or .none).
  • The native smoke makes the first window frameless and uses real pointer input (xdotool on Linux, mouse_event on Windows). A drag must move the window, and a right-edge drag must widen it. On GTK, a forged drag message without a held button must not move the window. macOS reads back the background-movement setting, because synthesizing input there needs Accessibility permission. CI installs xdotool and passes --require-input, so missing tools fail instead of skipping. Breaking the button check, the move, or the edge resize each makes the smoke fail.
  • Backend files are now included in zig build test, and the edge hit-test has a direct unit test.

Engine-level navigation decisions

  • native.Options.navigation_handler / Window.setNavigationHandler() decide page and frame navigations in the engine, like upstream's WebKitGTK decide-policy handler, on all three backends (GTK decide-policy, WKWebView decidePolicyForNavigationAction, WebView2 NavigationStarting + FrameNavigationStarting). The handler gets a borrowed { url, kind } and returns false to cancel and keep the page.
  • The host's own initial load and navigate() are not reported. Every server redirect hop is reported, because WKWebView has no public redirect flag and this is the only behavior consistent across engines. It also lets a handler stop a redirect to another origin. A URL that cannot be read cancels the navigation.
  • The native smoke blocks a script navigation and a link click while the page stays alive, requires an allowed favicon.ico navigation to report its 302 hop to favicon.svg, and checks that a host navigation back is not reported. Mutations (no host exemption, ignored decision, and on GTK skipped redirects) each fail it on Linux and macOS.
  • This closes the last open semantic gap; only the intentional default-presentation policy row remains.

Tests and tooling

  • zig build test -Dtest-filter=<name> runs selected tests.
  • Socket integration tests that were previously gated to Linux now also run on macOS. Only Windows skips them.
  • Fixed a test race: a test changed client evaluation IDs before the client registration had finished.
  • Fixed macOS CI races in the newly enabled socket tests. Shutting down a socket the server already closed is now tolerated. Tests wait for queued handlers and for client registration, because CHECK_TOKEN is acknowledged before the client is registered.
  • App.destroyWindow() now sends the close notification before starting the background reaper. Before, a handler destroying its own window could be cancelled mid-broadcast on Linux.
  • The authentication-deadline test pings for three seconds of wall time. Before, it used a fixed loop of 30 pings, which could outlast the five-second deadline on slow runners.

Validation

  • zig build test (74/74) and zig build on macOS with Zig 0.16.0. The full suite also passed 40 runs with 8 copies in parallel.
  • zig build -Dtarget= for x86_64-linux, aarch64-linux, x86_64-windows, x86_64-macos, and aarch64-macos
  • rg 'webui_new|pub extern fn webui_' src finds nothing

Running.wait() now evaluates every window on its own. A backend close
ends only that window, other disconnects get the reconnect grace from
the latest disconnect, and a new client clears the close intent.

Add App.Options.startup_timeout (15 s by default) for first-connection
waiting, restarted by open/openWithBrowser and extended by page or
bridge requests, plus Running.requestExit() as the webui_exit
equivalent.
Call now carries the matched binding name and whether it came from an
explicit call or a DOM click. Call and Event expose the Cookie header of
the client's WebSocket upgrade, like upstream webui_event_t.cookies.

The header is copied per connection and bounded by the new
Limits.max_cookie_size; larger upgrades are answered with 431.
favicon.ico and favicon.svg now resolve the custom icon, then a readable
directory file, then the built-in default SVG, with favicon.ico
redirecting to favicon.svg like upstream. The origin root serves the
same default for pages that declare no icon link.
Content.site combines optional embedded HTML, a resource handler, a
root folder, and an entry file, resolved in upstream order: handler,
virtual-directory index probing, root HTML, folder files, then the
default favicon or 404. A handler declines a path with an empty 404.

The entry redirects the root like webui_show(window, "page.html").
Plain custom content gains the same virtual-index probing, and the
directory branch is shared with site folders.
Window.installContent swaps the served content for later requests and
leaves connected pages in place, like changing the root folder or file
handler of a shown upstream window. External URLs change the page
origin, so either side being external returns NavigationRequired.
Rejected clients may already be disconnected by the server, which macOS
reports as an unconnected socket on shutdown. An unbound call is answered
without entering the event queue, so the integration test now waits for
the queued connect and click handlers instead of reading their flags.
The server acknowledges CHECK_TOKEN before registering the client, so
tests that just authenticated poll for the registration instead of
reading isShown immediately.
Monitors now live in a per-window group so one window's monitor can be
cancelled without stopping the others. They are spawned with concurrent
before the server starts, so an unavailable worker fails start with an
error instead of running the endless poll loop inline.
createWindow now serves a window at once when called after start, with
fresh credentials, its folder, and its own monitor. destroyWindow mirrors
webui_destroy: it unregisters the window immediately, notifies connected
pages, closes later transports with 1001, and cancels the window's
handlers, monitor, and managed browser in the background.

Window state is reference counted by the app list, upgrade records,
in-flight requests, and deferred replies, and the window registry is
guarded by a read-write lock, so a destroyed window is freed only after
its last user finishes. Running.stop completes any pending cleanup.
Client.window exposes the owning window so handlers can destroy it.
…dlers

destroyWindow spawned the reaper before sending the close notification.
When called from one of the window's own handlers, the reaper could cancel
that handler mid-broadcast, the write failed with error.Canceled, and the
transport was closed without the notification. The reaper now starts after
the broadcast, still under the registry lock so it cannot race stop().
The loop slept 30 times for 100 ms. On a slow runner that exceeded the
five-second authentication deadline, so the server closed the socket under
the pings and the test failed with a write error. Ping for three seconds
of wall time and stop pinging once the socket is closed.
Add upstream's toolbar-hiding userChrome.css and a user.js that enables
it and turns off the default-browser check, the close warning, tabs in the
title bar, and the first-run pages. user.js is rewritten on every call and
always sets browser.display.document_color_use, so a changed high-contrast
setting takes effect even after Firefox stored the old value in prefs.js.
Firefox windows without a caller profile now get a per-window generated
profile like Chromium windows, prepared with the app-mode settings before
each launch. high_contrast = false is honoured through the profile; a
caller profile is never modified, so it still returns
UnsupportedBrowserHighContrast.

Snap Firefox cannot see the host /tmp. When /snap/bin/firefox exists, the
Firefox root moves into the snap's user directory, found through the
passwd home as snapd does, because Zig 0.16 exposes no process environment
here. Profile lookups therefore take an Io, and launch receives the
generated profile separately from the caller's controls.
Chrome and Chromium both install and register chrome.exe. Like upstream,
a folder holding Google's initial_preferences or legacy master_preferences
file is Google Chrome and any other chrome.exe is Chromium. Every chrome.exe
candidate from PATH and App Paths is classified, so a Chromium chrome.exe is
no longer reported as Chrome and registered Chromium is discovered.
Discovery now also checks ~/Applications and then asks Spotlight for the
browser's bundle identifier, which finds bundles wherever LaunchServices
registered them. Upstream uses open -R -a for this lookup, which reveals
the application in Finder; mdfind has no side effects and a missing or
disabled index only yields no result.
COM vtable entries and GetProcAddress results are byte-aligned opaque
pointers, while function pointers require target alignment on aarch64.
Assert that alignment with @aligncast so aarch64-windows compiles.
The WebMessageReceived close handler becomes an instance of a generic
COM event handler parameterized by interface ID and callback, so further
ICoreWebView2 events reuse the same reference counting and owner
detachment instead of duplicating them.
Like upstream, every non-empty page title now replaces the native host
title: GTK listens to notify::title, WebView2 to DocumentTitleChanged,
and Cocoa observes WKWebView.title through KVO, which also reports
document.title changes after load that upstream's didFinishNavigation
hook misses. Options.title is the initial title and setTitle applies at
once until the page reports another one. An empty title keeps the host
title; WebView2 reports its own default for untitled documents.

Options.follow_page_title and Window.setFollowPageTitle let hosts keep
titles under their own control, and Window.title reads the current host
title. The native smoke verifies following, setTitle, empty titles, and a
non-following window on every platform.
Environment, controller, and document-script creation were bounded to
15 s. Hosted Windows runners take about 7 s to create two windows and
occasionally exceed 15 s while spawning the browser processes, failing
with NativeInitializationTimeout although the runtime is healthy. Raise
the bound to 60 s; it still turns a runtime that never answers into an
explicit error.
WebKitGTK has no CSS app-region support. As upstream WebUI does on Linux,
a document-start script finds the nearest `--webui-app-region` value and,
once the primary button moves over a `drag` area, posts one request on a
dedicated script-message channel. The host starts a window-manager move
only while the primary button is actually held, so a page cannot move the
window by posting on its own.

Frameless resizable windows also gain upstream's 6 px edge band: pressing
it starts a window-manager resize from the matching edge or corner, and
hovering shows the matching resize cursor, restoring the page cursor when
leaving the band.
…ames

Like upstream WebUI (PR #718), enable ICoreWebView2Settings9 non-client
region support so CSS `app-region: drag` areas act as the host caption.
Runtimes without Settings9 keep working and record that drag regions are
unavailable. Resizable frameless windows keep a WS_THICKFRAME sizing border
as upstream does; kiosk windows stay plain popups.
Match upstream WebUI's Cocoa adapter: frameless windows are movable by
their background. The setting follows frameless changes at creation, style
updates, and kiosk transitions; kiosk windows are never movable.
native.Window.dragRegion() returns the backend's drag model: the
`--webui-app-region` property on WebKitGTK, CSS `app-region` on WebView2,
background movement on Cocoa, or none on WebView2 runtimes without
Settings9, so unsupported drag regions are reported instead of being
silently ignored.
The native smoke makes the first window frameless, declares a page-wide
drag region, and synthesizes pointer input: xdotool under X11 and
mouse_event on Windows, from a worker so the Windows modal move and size
loops can run. It checks that a drag moves the window, a right-edge drag
widens it, and on GTK that a forged drag message without a held button
does not move it. macOS, which has no input synthesis without
Accessibility permission, verifies the background-movement setting.

CI installs xdotool and passes --require-input so missing input tools fail
instead of skipping.
…n gap

Describe each backend's drag-region model, the GTK held-button rule, edge
resizing, and portable CSS for pages, and move native interaction from the
open semantic gaps to the closed table with its smoke and unit evidence.
Add NavigationRequest, NavigationKind, and NavigationHandler with
Options.navigation_handler and Window.setNavigationHandler. The facade wraps
the handler in a callback scope that rejects reentrant deinit. WebKitGTK
answers decide-policy like upstream's policy handler, independent of any
bridge connection: the host's own loads are exempt, every redirect hop is
reported, and an unreadable request is cancelled.
Make the delegate the navigation delegate and answer
decidePolicyForNavigationAction exactly once on every path. The host flag is
consumed only by a main-frame decision, and server redirect hops reach the
handler because WKWebView exposes no public redirect flag.
Subscribe NavigationStarting and FrameNavigationStarting and cancel through
put_Cancel when the handler declines. Only the top-level event can consume the
host flag. NavigationStartingEventArgs3 supplies reload and back/forward
kinds when present, and an unreadable or invalid UTF-16 URL cancels.
Block a script navigation and a link click while the page stays alive, allow
a favicon.ico navigation and require its 302 hop to favicon.svg to be
reported, and confirm a host navigation back is not reported.
…c gap

Describe the handler contract, redirect and frame reporting, per-engine
kinds, and how to leave bridge-intercepted navigations to the engine. Move
native navigation policy to the closed table and add its method mapping.
Minimize, restore, maximize, kiosk, and visibility requests were issued with
at most one pump between them. X11 window managers apply them asynchronously
and GDK applies (un)maximize to a window it considers unmapped only locally,
so a later request could race an earlier one and leave GTK's toplevel resize
queue waiting forever; every later setSize was then ignored. Pump for a
bounded settle period after each change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant