Introduction
laufey is a web embedded framework: build cross-platform desktop apps with web technologies and your choice of browser engine.
It is built around a small C ABI that separates the browser engine (the backend) from your application logic (the runtime). You write the runtime in Rust against one portable API; laufey ships prebuilt backends — Chromium via CEF, the system WebView, and an engine-free Winit windowing backend — and your app runs on any of them.
use laufey::{Value, Window};
fn main() {
Window::new(800, 600)
.title("My App")
.bind("greet", |call| {
let name = call
.args
.first()
.and_then(|v| v.as_string())
.unwrap_or("World");
call.resolve(Value::String(format!("Hello, {name}!")));
})
.load("index.html");
}
laufey::main!(main);
Where to go next
- Architecture — how backends and runtimes fit together.
- C ABI — the
laufey.hcontract: entry points, the API table, and the value model. Read this if you’re implementing a backend or a binding. - Backends — CEF, WebView, and Winit, and how they differ.
- The feature pages — windows, JavaScript interop, menus, dialogs, tray, notifications, clipboard, and more — each with a usage example and its per-platform notes.
- Packaging & distribution — bundling, signing, and updates.
- Building — prerequisites and
maketargets.
The source lives at github.com/littledivy/laufey.
Architecture
Overview
laufey separates browser engines (backends) from application logic
(runtimes). Backends are native executables; runtimes are shared libraries
(.dylib/.so/.dll) loaded at startup. They communicate through a C ABI
defined in capi/include/laufey.h.
┌──────────────────┐ ┌───────────────────┐
│ Backend (exe) │ ──C ABI──▶ Runtime (dylib) │
│ CEF / WebView / ◀─────────│ User app logic │
│ Winit │ │ (links laufey capi) │
└──────────────────┘ └───────────────────┘
Backends
| Directory | Engine | Language | Window ownership |
|---|---|---|---|
cef/ | Chromium Embedded Framework | C++ | CEF Views (internal) |
webview/ | System webview (WKWebView / WebView2 / WebKitGTK) | C++ (per-platform) | Created directly |
winit/ | None (winit only, no web content) | Rust (winit) | Created directly |
An experimental Servo backend lives on the
servo branch.
Runtime (capi)
capi/ provides the Rust crate that runtimes link against. It wraps the raw C
function pointers from laufey_backend_api_t into safe Rust types (Window,
Value, JsCall, KeyboardEvent, MouseClickEvent, etc.).
Key Patterns
The C ABI contract (laufey_backend_api_t)
The central interface is a struct of function pointers
(capi/include/laufey.h). The backend fills this struct and passes a pointer to
laufey_runtime_init(). The runtime stores it for the process lifetime. Every
capability (navigation, JS execution, event handlers, window management) is a
nullable function pointer in this struct.
Adding a new API: add the field to laufey_backend_api_t in
capi/include/laufey.h, then implement it in every backend. Both C++ backends
include the canonical header from capi/include/ via the LAUFEY_INCLUDE_DIR
CMake variable — there is no second copy to sync.
Callback registration (event handlers)
All event handlers follow the same pattern:
- Define a C callback type (
laufey_keyboard_event_fn,laufey_mouse_click_fn) - Add a
set_*_handler(backend_data, callback, user_data)function pointer to the API struct - Backend stores the callback+user_data behind a mutex
- Backend dispatches from its native event handler, passing the user_data back
On the runtime side (capi/src/lib.rs), an unsafe extern "C" trampoline
converts C types to Rust types and forwards to a stored Box<dyn Fn(Event)>.
Events are non-consuming – handlers always return the event to the underlying engine. This is an interception model, not a consumption model.
C++ backend code sharing (backend-common)
The CEF and webview backends both link backend-common/, a CMake static library
that holds platform implementations of APIs the two backends would otherwise
duplicate. Each backend add_subdirectorys it and links laufey_backend_common
from its platform branch.
The bridge is intentionally minimal — common code never touches the
backend-specific laufey_value_t types. Each backend pre-parses
laufey_value_t into plain C++ structs (laufey_common::NotificationOptions,
etc.) before calling into common functions. Header:
backend-common/include/laufey_backend_common.h.
Currently shared:
| Area | macOS | Windows | Linux |
|---|---|---|---|
| Notifications | notifications_mac.mm (UN) | notifications_win.cc (NIIF) | notifications_linux.cc (notify-send) |
| Dialogs | dialog_mac.mm (NSAlert) | dialog_win.cc (MessageBoxW + PowerShell prompt) | dialog_linux.cc (gtk_message_dialog) |
| Permissions | permissions_mac.mm (UN auth) | permissions_stub.cc (always granted) | permissions_stub.cc (always granted) |
| Dock | dock_mac.mm (badge / bounce / visible / dock menu storage / reopen handler) | per-backend (FlashWindowEx) + title_badge.cc for the badge | per-backend (gtk_window_set_urgency_hint) + title_badge.cc for the badge |
| Key mapping | keymap_mac.mm (NSEvent → W3C) | keymap_vk.cc (VK → W3C; CEF uses on every platform) | keymap_gdk.cc (GDK → W3C) |
| App / context menu | menu_mac.mm (NSMenu) | capi/include/win32_menu.h (HMENU + SetMenu / TrackPopupMenu) | menu_linux.cc (GtkMenu / GtkMenuBar) |
| Tray icons | tray_mac.mm (NSStatusItem) | tray_win.cc (Shell_NotifyIcon + WIC + HMENU) | tray_linux.cc (libappindicator + g_idle_add) |
| Option parsing | parse_options.cc (compiled on every platform; bridges laufey_value_t → plain structs) | ||
| Title-prefix badge bookkeeping | title_badge.cc (ApplyTitlePrefixBadge — used by CEF Win+Linux and webview Win+Linux for Dock-badge fallback) |
Notes:
win32_menu.his the older shared header-only library for Windows menu construction; it predatesbackend-commonand stays as-is. The two patterns coexist — both backends usewin32_menufor Windows app/context menus, andbackend-commonfor the rest.- The dock fallback on Windows/Linux still iterates per-window state inside each
backend (because the native title-get/set APIs differ —
SetWindowTextW(HWND)vsgtk_window_set_title(GtkWindow*)vs CEF’sCefWindow::SetTitle), but the saved-titles bookkeeping and"(badge) " + titlestring concatenation are unified inlaufey_common::ApplyTitlePrefixBadge. - FlashWindow (Windows) and
gtk_window_set_urgency_hint(Linux) forbounce_dockremain per-backend — each is ~5 LOC and the native call differs enough that an abstraction would cost more than it saves.
To add a new shared API: declare it in laufey_backend_common.h, add the
implementation file(s) to backend-common/CMakeLists.txt, then call it from
each backend’s existing API trampoline.
Winit backend code sharing (backend-winit-common)
The winit/ backend uses winit for windowing; the Servo backend on the servo
branch shares this same code. Shared code lives in
backend-winit-common/src/lib.rs:
BackendAccesstrait: each backend implements this to provide access to itsCommonState, event loop proxy, and event type mapping.define_common_backend_fns!macro: generates theunsafe extern "C"functions for all common operations (title, size, position, visibility, event handlers, etc.).fill_common_api!macro: wires those generated functions into aLaufeyBackendApistruct.CommonState: holds pending window mutations (Mutex<Option<T>>) and event handler callbacks.handle_common_event(): processesCommonEventvariants against a winitWindow.
To add a new winit-based API: add the pending state to CommonState, the
function to define_common_backend_fns!, the assignment to fill_common_api!,
and the dispatch to handle_common_event(). The winit backend picks it up
automatically (and the Servo branch, if rebased).
Pending state pattern (async window ops)
Backend API functions are called from the runtime thread, but window operations must happen on the UI thread. The pattern is:
- Store the desired value in a
Mutex<Option<T>>onCommonState - Send an event via the winit
EventLoopProxy - On the UI thread, take the pending value and apply it to the window
C++ backends use platform-specific dispatch instead (dispatch_async on macOS,
PostMessage on Windows, g_idle_add on Linux).
CEF: no native mouse/input handlers
CEF provides CefKeyboardHandler for keyboard events but has no equivalent
CefMouseHandler. This is because CEF Views creates and owns the native
window internally – the embedder has no direct access to the native event loop.
Workaround: platform-specific native event monitors that hook into the OS event system:
| Platform | Technique | File |
|---|---|---|
| macOS | [NSEvent addLocalMonitorForEventsMatchingMask:] | cef/src/main_mac.mm |
| Windows | WM_*BUTTON* messages in WindowProc (via CefWindow::GetWindowHandle() + subclassing) | cef/src/main_win.cc (TODO) |
| Linux | GTK button-press-event / button-release-event signals (via CefWindow::GetWindowHandle()) | cef/src/main_linux.cc (TODO) |
The monitor functions (InstallNativeMouseMonitor() /
RemoveNativeMouseMonitor()) are declared in cef/src/runtime_loader.h and
called from LaufeyWindowDelegate::OnWindowCreated / OnWindowDestroyed in
cef/src/app.cc. This is the same approach Electron uses – Electron creates
native windows directly (bypassing CEF Views), but since we use CEF Views, we
instead install post-hoc monitors on the window CEF creates.
Webview backends: direct native window access
Unlike CEF, the webview backends create their own native windows, so event interception is straightforward:
| Platform | Keyboard | Mouse |
|---|---|---|
macOS (webview_macos.mm) | NSEvent addLocalMonitorForEventsMatchingMask: for key events | Same mechanism for mouse events |
Windows (webview_windows.cc) | WM_KEYDOWN / WM_KEYUP in WindowProc | WM_*BUTTON* in WindowProc |
Linux (webview_linux.cc) | GTK key-press-event / key-release-event signals | GTK button-press-event / button-release-event signals |
W3C UI Events key mapping
Keyboard events expose key (logical, e.g. "a", "Enter") and code
(physical, e.g. "KeyA", "Enter") following the W3C UI Events specification.
Each platform has its own mapping:
- winit backends:
winit_key_to_string()/winit_code_to_string()inbackend-winit-common - CEF:
CefKeyCodeToString()/CefKeyCodeToCode()incef/src/app.cc(maps Windows virtual key codes) - webview macOS:
NSEventKeyToString()/NSEventKeyCodeToCode()(maps macOS key codes) - webview Windows:
VirtualKeyToKey()/VirtualKeyToCode()(maps Win32 VK codes) - webview Linux:
GdkKeyvalToKey()/GdkKeycodeToCode()(maps GDK keyvals and evdev hardware keycodes)
Mouse button mapping
Mouse buttons are normalized to LAUFEY_MOUSE_BUTTON_* constants.
Platform-specific mappings:
- NSEvent
buttonNumber: 0=left, 1=right, 2+=other (detect via event type mask) - Win32: separate
WM_*BUTTON*messages per button;XBUTTON1/XBUTTON2for back/forward - GDK:
event->button: 1=left, 2=middle, 3=right, 8=back, 9=forward
Value marshalling
The laufey API has a rich value type (laufey_value_t) for JS interop. Backends
own the value representation:
- CEF: wraps
CefValue/CefListValuedirectly - Webview: uses a custom
Valueclass with JSON serialization for JS communication - Winit backends: stub implementations (no JS engine)
The runtime crate (capi/src/lib.rs) wraps these into a Rust Value enum via
the function pointer API, completely opaque to the value’s backend
representation.
Modifier flags
All platforms normalize keyboard modifiers to a shared bitmask:
LAUFEY_MOD_SHIFT = 1 << 0
LAUFEY_MOD_CONTROL = 1 << 1
LAUFEY_MOD_ALT = 1 << 2
LAUFEY_MOD_META = 1 << 3
Each platform maps from its native representation (NSEventModifierFlags,
GetKeyState(), GdkModifierType, CefEventFlags, winit ModifiersState).
C ABI
laufey is built around a single C header,
capi/include/laufey.h.
It defines the boundary between a backend (a native executable embedding a
browser engine) and a runtime (a shared library holding the application
logic). The backend implements the ABI; the runtime consumes it.
LAUFEY_API_VERSION (currently 28) versions the contract. The version field
on the API table lets a runtime detect the backend’s vintage and avoid calling
function pointers a backend predates (older backends leave new pointers NULL).
Runtime entry points
A runtime is a .dylib/.so/.dll that exports three symbols:
Symbol (*_SYMBOL macro) | Signature | Role |
|---|---|---|
laufey_runtime_init | int(const laufey_backend_api_t* api) | Backend hands the runtime the API table. Stash it; return 0 on success. |
laufey_runtime_start | int(void) | Run application setup (create windows, register handlers). Returns when ready. |
laufey_runtime_shutdown | void(void) | Tear down before the process exits. |
The backend dlopens the runtime, resolves these symbols, calls init then
start, and drives the OS event loop. Control flows backend → runtime through
the API table, and runtime → backend through the registered callbacks.
The API table
laufey_backend_api_t is a struct of function pointers plus two data fields:
struct laufey_backend_api {
uint32_t version; // == LAUFEY_API_VERSION the backend was built against
void* backend_data; // opaque; pass back as the first arg of every call
/* ... function pointers ... */
};
Every function takes backend_data as its first argument, so the table is a
hand-rolled vtable with no global state. Windows are referenced by an opaque
uint32_t window_id returned from create_window.
The pointers group into:
- Window lifecycle —
create_window,create_window_ex(style flags, seeLAUFEY_WINDOW_FLAG_*, includingLAUFEY_WINDOW_FLAG_TRANSPARENTfor a transparent background),close_window,navigate,set_title, size/position get+set,set_resizable/is_resizable,set_always_on_top/is_always_on_top,set_window_opacity/get_window_opacity(whole-window alpha, API ≥ 28),show/hide/is_visible,focus,quit,post_ui_task. - Value marshalling — the
value_*family (below). - JavaScript interop —
set_js_call_handler,js_call_respond,invoke_js_callback,release_js_callback,execute_js,set_js_namespace,poll_js_calls,set_js_call_notify. - Event handlers —
set_keyboard_event_handler,set_mouse_click_handler,set_mouse_move_handler,set_wheel_handler,set_cursor_enter_leave_handler,set_focused_handler,set_resize_handler,set_move_handler,set_close_requested_handler. - Window handles —
get_window_handle,get_display_handle,get_window_handle_type(for GPU surface creation). - Menus —
set_application_menu,show_context_menu,open_devtools. - Dialogs —
show_dialog,string_free. - Dock / taskbar —
set_dock_badge,bounce_dock,set_dock_menu,set_dock_visible,set_dock_reopen_handler. - Tray —
create_tray_icon,destroy_tray_icon,set_tray_icon(_dark),set_tray_tooltip,set_tray_menu, click handlers,get_tray_icon_bounds. - Notifications —
show_notification,close_notification. - Permissions —
query_permission,request_permission. - Custom URL scheme handler (API ≥ 26) —
register_scheme_handler,scheme_request_read_body,scheme_response_begin,scheme_response_write,scheme_response_finish.
See the feature pages for behavior and per-platform differences.
Values (laufey_value_t)
laufey_value_t is an opaque, dynamically-typed value used for everything
crossing the JS ↔ native boundary (call arguments, results, menu templates,
notification options). It models the JSON types plus binary blobs and
JS-callback handles:
- Inspect:
value_is_null/_bool/_int/_double/_string/_list/_dict/_binary/_callback. - Read:
value_get_bool/_int/_double;value_get_string(returns a heap buffer freed withvalue_free_string); list access (value_list_size,value_list_get); dict access (value_dict_get,value_dict_has,value_dict_size,value_dict_keys+value_free_keys);value_get_binary;value_get_callback_id. - Build:
value_null/_bool/_int/_double/_string/_list/_dict/_binary(constructors takebackend_data), thenvalue_list_append/_set,value_dict_set. - Free:
value_free.
Ownership. Constructors return a value the caller owns and must value_free
(unless handed off). Functions that accept a template — set_application_menu,
show_context_menu, set_tray_menu, set_dock_menu, show_notification —
take ownership of the passed value and free it themselves.
A _callback value wraps a JS function passed as an argument: read its
value_get_callback_id, then call it later with invoke_js_callback(id, args)
and free it with release_js_callback(id).
JavaScript call flow
- The runtime exposes a namespace in the page (
set_js_namespace, default"Laufey") and registersset_js_call_handler. - Page JS calls
Laufey.someMethod(args…); the backend invokes the handler with acall_id, the method name, and the arguments as alaufey_value_tlist. - The runtime does its work and replies with
js_call_respond(call_id, result, error)— resolving or rejecting the JS-side promise.
execute_js runs a script in a window and delivers its result/error through a
laufey_js_result_fn. When the runtime services calls off the UI thread, the
backend signals readiness via set_js_call_notify and the runtime drains the
queue with poll_js_calls.
Custom URL scheme handler (API ≥ 26)
A custom scheme handler lets the runtime service webview requests for a
registered URL scheme (e.g. app://) entirely in-process — no network socket,
port, or localhost exposure. This is how the Deno desktop runtime serves an
embedded browser over an in-memory byte channel instead of a TCP loopback.
- The runtime calls
register_scheme_handler(scheme, handler, on_cancel, user_data)with the scheme name (e.g."app", no://). The backend registers it as a standard, secure, fetch/CORS-enabled scheme and installs a handler factory. - When the webview requests
<scheme>://…, the backend invokeshandlerwith request metadata (method, URL, headers) and an opaquelaufey_scheme_exchange_t*. Headers use a flatname\0value\0…\0encoding (headers_lencounts every terminating NUL). - The runtime pulls the request body (if any) with
scheme_request_read_body(returns >0 bytes, 0 at EOF, <0 on error), then streams the response:scheme_response_begin(status, headers)once,scheme_response_write(bytes)any number of times, andscheme_response_finishto release the exchange.
If the webview cancels (navigation away, window closed) before the response
finishes, scheme_response_write / scheme_request_read_body return negative;
the runtime should stop and call scheme_response_finish. Backends predating
API version 26 leave these pointers NULL; the runtime must null-check and fall
back to a socket transport.
Threading
All API calls must happen on the UI thread the backend’s event loop runs on.
post_ui_task hops onto it from another thread. show_dialog blocks on the UI
thread but pumps OS events so other windows stay responsive.
Backends
A backend is the native executable that hosts a browser (or windowing) engine
and implements the C ABI. laufey ships three; a fourth is on a
branch. All implement the same laufey_backend_api_t, so a runtime is portable
across them — the differences are in engine, process model, size, and a few
features that a given engine can’t express on a given OS (see
the feature pages).
| Backend | Engine | Process model | Bundled | JS bridge |
|---|---|---|---|---|
| CEF | Chromium 144 | multi-process | yes | yes |
| WebView | system native | single | no | yes |
| Winit | none | single | n/a | no |
Platform support is x86_64 + aarch64 on macOS and Linux, x86_64 on Windows. There is also an iOS backend (UIKit + WKWebView, statically linked) — see iOS. Android is not supported.
CEF
Embeds Chromium 144 through the Chromium Embedded Framework and runs Chromium’s real multi-process architecture — a browser process plus renderer, GPU, and utility subprocesses, with the same rendering and DevTools you get in Chrome. The engine is bundled into the app, so binaries are large but rendering is identical everywhere and independent of the host OS.
Sources live in cef/;
shared native features come from backend-common. On Windows the backend links
the static CRT (/MT), so everything it links — including backend-common — is
built /MT.
Linux caveat: the application menu doesn’t work under CEF (a GtkMenuBar must
be packed into a GtkWindow above the browser, and reparenting CEF into a
client-owned GtkWindow via CefWindowInfo::SetAsChild breaks on XWayland).
Context menus do work, because GtkMenu popups need no GtkWindow container.
WebView
Delegates to the platform’s native web engine — WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux. The engine is never bundled, so apps stay small, at the cost of rendering that varies by OS and engine version. Single-process.
Sources live in
webview/, one file
per platform (webview_macos.mm, webview_windows.cc, webview_linux.cc),
sharing backend-common for menus, tray, dialogs, dock, and notifications.
Winit
Engine-free. It creates native windows via
winit for apps that draw their own
content — GPU surfaces, custom renderers — without loading a web engine. There
is no JS bridge; get_window_handle / get_display_handle expose the raw
handles needed to create a rendering surface. Sources in
winit/.
Servo (experimental)
A Servo-based backend is preserved on the
servo branch for future
work and is not part of the mainline build.
backend-common
CEF and WebView share their native-API implementations (menus, tray, dock,
dialogs, notifications, key mapping) in
backend-common/,
included as a CMake subdirectory by each backend. The winit backend shares its
non-engine pieces through backend-winit-common instead.
iOS
laufey has an iOS backend: the C ABI (the same laufey_backend_api_t the
desktop backends implement) on UIKit + WKWebView. A laufey runtime drives a
native iOS app — web UI in a WKWebView, talking to native code through the
usual JS bridge. Verified building, signing, and running on a physical device.
How it differs from desktop
The desktop model is a backend executable that dlopens a runtime
dylib. iOS forbids loading arbitrary dylibs and the OS owns the app lifecycle
(UIApplicationMain), so on iOS the two collapse into one statically-linked
app binary:
MyApp.app/MyApp ← one signed Mach-O
├─ UIKit shell main_ios.mm: UIApplicationMain → UIViewController + WKWebView
├─ laufey iOS backend webview_ios.mm: fills laufey_backend_api_t
└─ laufey runtime your app (capi), linked as a static lib
The runtime’s laufey_runtime_init / _start / _shutdown are resolved at
link time (via RuntimeLoader::LoadStatic) instead of dlopen. A weak
main in main_ios.mm lets the same file serve either a C-linked or a
Rust-linked app.
- A “window” is a
UIWindow+ rootUIViewControllerhosting aWKWebView. - The JS bridge, value marshalling, and init script are shared verbatim with the
desktop WebView backend (
runtime_loader.cc,laufey_json.h,init_script.h). - Desktop-only surfaces (menus, tray, dock) are no-ops; iOS doesn’t link
backend-common, only its value marshalling.
Sources:
webview/src/webview_ios.mm,
webview/src/main_ios.mm.
Build
The iOS backend is wired into the webview CMake build. It needs a laufey runtime
compiled as a static lib for the iOS target (e.g. the
examples/ios_hello
runtime):
# 1. runtime static lib (device: aarch64-apple-ios; simulator: aarch64-apple-ios-sim)
cargo build --release -p ios_hello --target aarch64-apple-ios
# 2. iOS app via CMake (point LAUFEY_IOS_RUNTIME_LIB at the static lib)
cd webview
cmake -B build-ios -G Ninja \
-DCMAKE_SYSTEM_NAME=iOS \
-DCMAKE_OSX_ARCHITECTURES=arm64 \
-DCMAKE_OSX_SYSROOT=iphoneos \
-DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \
-DCMAKE_C_COMPILER="$(xcrun -f clang)" \
-DCMAKE_CXX_COMPILER="$(xcrun -f clang++)" \
-DLAUFEY_IOS_RUNTIME_LIB="$PWD/../target/aarch64-apple-ios/release/libios_hello.a"
cmake --build build-ios
# → build-ios/laufey_webview.app
For the simulator, use -DCMAKE_OSX_SYSROOT=iphonesimulator and the
aarch64-apple-ios-sim runtime lib; install/launch with
ios/build.sh or
xcrun simctl install/launch.
Sign + package an .ipa
ios/package-ipa.sh
sets the bundle id to match a provisioning profile’s App ID, embeds the profile,
signs with the profile’s entitlements, and zips a Payload/:
ios/package-ipa.sh webview/build-ios/laufey_webview.app \
<bundle-id-matching-profile> \
"Apple Distribution: Your Team (TEAMID)" \
~/Library/MobileDevice/Provisioning\ Profiles/<uuid>.mobileprovision \
laufey.ipa
The bundle id must match the profile’s App ID, and the device must be in the profile’s provisioned devices (ad-hoc) or the profile must be a distribution profile for the App Store.
Install on a device
xcrun devicectl list devices # find the device id
xcrun devicectl device install app --device <id> laufey.ipa
xcrun devicectl device process launch --device <id> <bundle-id>
Status
- Verified: CMake build → device binary → signed
.ipa→ install + launch on a physical iPhone, and the same backend on the simulator. - Not yet: the iOS backend isn’t exercised by CI; App Store submission flow is not automated.
Window management
Every laufey application is built around one or more native windows. A Window
controls its title, size, position, resizable and always-on-top flags, opacity,
visibility, and focus. The type is a builder, so you can configure a window
fluently when you create it, and each property also has a plain setter you can
call later while the window is open.
#![allow(unused)]
fn main() {
use laufey::Window;
let win = Window::new(800, 600)
.title("My App")
.position(100, 100)
.resizable(true)
.opacity(0.95)
.load("index.html"); // or .navigate("https://example.com")
win.set_size(1024, 768);
let (width, height) = win.get_size();
win.focus();
win.hide();
}
A few properties can only be chosen when the operating system creates the window
and cannot be changed afterwards: whether the window is frameless (drawn without
operating-system chrome), whether it is a non-activating panel that does not
steal keyboard focus, and whether it has a transparent background. You set those
through Window::new_with_options. Everything else is a live setter. All
positions and sizes are expressed in density-independent pixels with the origin
at the top-left of the screen. The Winit backend can create and manage windows,
but because it has no web engine it cannot navigate to a URL or execute
JavaScript.
Opacity and transparency
These are two distinct things:
-
Opacity (
Window::opacity/set_opacity/get_opacity) fades the entire window — web content and native chrome alike — by a uniform factor in0.0..=1.0, where1.0is fully opaque (the default), like CSSopacityon the whole window. It is a live setter you can animate at runtime. The web backends implement it on every desktop platform (macOSNSWindow.alphaValue, Windows layered-window alpha, Linuxgtk_widget_set_opacity). The Winit backend has no opacity API, so the call is a no-op there andget_opacityreturns1.0.#![allow(unused)] fn main() { win.set_opacity(0.8); // 80% opaque } -
Transparency (
WindowOptions::transparent) gives the window a transparent background so the web content’s own alpha composites against whatever is behind the window. Any region the page leaves transparent (e.g. atransparentroot background) shows the desktop through it. This must be chosen at creation time and is commonly paired withframeless.#![allow(unused)] fn main() { use laufey::{Window, WindowOptions}; let win = Window::new_with_options( 400, 300, WindowOptions { frameless: true, transparent: true, ..Default::default() }, ) .load("index.html"); }Transparency is supported by the system-WebView backend on macOS and on Linux (WebKitGTK, on a compositing window manager), and by the Winit backend. It is not supported by the Windows WebView2 backend or the CEF backend, which paint an opaque window background; the flag is ignored there.
JavaScript interop
JavaScript interop lets the page and your Rust code call each other. You expose native functions under a namespace object in the page; when the page calls one, it receives a promise, and your Rust handler resolves or rejects it. Your code can also evaluate a script in a window and read back its result.
#![allow(unused)]
fn main() {
use laufey::{Value, Window};
let win = Window::new(800, 600)
.bind("greet", |call| {
let name = call.args.first().and_then(|v| v.as_string()).unwrap_or("World");
call.resolve(Value::String(format!("Hello, {name}!")));
})
.bind_async("fetchUser", |call| async move {
let user = load_user().await;
call.resolve(user);
})
.load("index.html");
// Evaluate a script in the page and read the result.
win.execute_js("document.title", Some(|result, _error| println!("{result:?}")));
}
// In the page:
const message = await Laufey.greet("Ada"); // "Hello, Ada!"
Arguments and results cross the boundary as a Value, which models the JSON
types — null, boolean, integer, double, string, list, and dictionary — along
with binary blobs. When the page passes a JavaScript function as an argument, it
arrives as a callback value that you can invoke later and must release when you
are finished with it. The namespace object is named Laufey by default; call
laufey::set_js_namespace before creating any windows to change it. All
handlers run on the user-interface thread. None of this is available on the
Winit backend, which has no JavaScript engine.
Menus
laufey supports three kinds of menus: an application menu bar, per-window
context menus, and the developer tools. A menu is described by a slice of
MenuItem values, which can be regular items, submenus, separators, or standard
roles such as quit, copy, and paste. Items may carry a keyboard accelerator.
When the user clicks an item that has an identifier, your callback is invoked
with that identifier.
Regular items also support a few visual properties, mirroring Electron’s
MenuItem: checked (a checkmark, all platforms), icon (a file path to a PNG
image — macOS and Windows, unsupported on Linux; on macOS a monochrome
black+alpha PNG is treated as a template and tints to white on selection, while
Windows renders it as-is), and tooltip (hover text — macOS only).
#![allow(unused)]
fn main() {
use laufey::MenuItem;
let menu = [MenuItem::Submenu {
label: "File".into(),
items: vec![
MenuItem::Item {
label: "Open".into(),
id: Some("open".into()),
accelerator: Some("CmdOrCtrl+O".into()),
enabled: true,
checked: false,
icon: Some("icons/open.png".into()), // file path to a template PNG
tooltip: Some("Open a file".into()),
},
MenuItem::Separator,
MenuItem::Role { role: "quit".into() },
],
}];
win.set_menu(&menu, |id| println!("menu: {id}"));
win.show_context_menu(x, y, &menu, |id| println!("context: {id}"));
win.open_devtools();
}
On macOS the application menu is the global menu bar at the top of the screen, and laufey swaps it as windows take focus. On Windows and Linux the menu is attached to the individual window. A context menu is a pop-up shown at a point you specify, in window coordinates.
The application menu does not work under the CEF and Winit backends on Linux. A
GtkMenuBar must be packed into a GtkWindow placed above the browser, and
reparenting CEF into a client-owned GtkWindow through
CefWindowInfo::SetAsChild breaks on XWayland, where cross-client X11 child
windows are not supported natively. Context menus work everywhere, because a
GtkMenu pop-up does not need a containing window.
Native dialogs
laufey can show the operating system’s standard alert, confirmation, and prompt dialogs. Each call is modal and blocks until the user dismisses the dialog, then returns the user’s response. A dialog can be attached to a specific window or shown at the application level.
#![allow(unused)]
fn main() {
win.alert("Heads up", "File saved.");
if win.confirm("Delete", "Are you sure?") {
// The user clicked OK or Yes.
}
if let Some(name) = win.prompt("Name", "What's your name?", "World") {
println!("hello {name}");
}
}
Although the call blocks the calling thread, the underlying platform routine —
runModal on macOS, MessageBoxW on Windows, and gtk_dialog_run on Linux —
keeps pumping operating-system events while the dialog is open, so your other
windows continue to render and respond. A prompt returns the text the user
entered, or None if the user cancelled. The same three operations are also
available as the application-scoped free functions laufey::alert,
laufey::confirm, and laufey::prompt.
On the CEF and WebView backends, the page’s own alert(), confirm(), and
prompt() calls are routed to these native dialogs. The Winit backend has no
web engine, so it has no page dialogs to route.
Input events
A window can deliver native keyboard, mouse, wheel, and cursor enter/leave events to your runtime. The handlers run before the events reach the page, which lets the application observe or react to raw input.
#![allow(unused)]
fn main() {
let win = Window::new(800, 600)
.on_keyboard_event(|e| println!("{} {:?}", e.key, e.modifiers))
.on_mouse_click(|e| println!("button {} at {},{}", e.button, e.x, e.y))
.on_wheel(|e| println!("scroll {},{}", e.delta_x, e.delta_y))
.on_cursor_enter_leave(|e| println!("entered: {}", e.entered))
.load("index.html");
}
Keyboard events carry the W3C key and code strings together with a modifier
bitmask. Mouse events carry the button, the pressed or released state, the
cursor position, the active modifiers, and the click count. Each backend
translates its own native event source — Chromium’s event path under CEF,
NSEvent on macOS, GDK on Linux, and the Win32 message loop on Windows — into
this common shape, so the same handler works on every backend.
Window events
A window reports lifecycle events as they happen: focus and blur, resize, move, and a request to close. These are commonly used to persist a window’s geometry between runs or to intervene before the window goes away.
#![allow(unused)]
fn main() {
let win = Window::new(800, 600)
.on_focused(|focused| println!("focused: {focused}"))
.on_resize(|w, h| println!("resized {w}x{h}"))
.on_move(|x, y| println!("moved {x},{y}"))
.on_close_requested(|| println!("user clicked close"))
.load("index.html");
}
The close-requested handler fires when the user clicks the window’s close button, before the window is destroyed. This gives the runtime a chance to ask for confirmation or save unsaved work first.
Window handles (GPU surfaces)
When you want to draw a window’s contents yourself — with a GPU API such as wgpu, Vulkan, or Metal — rather than load web content, laufey gives you the raw operating-system handles for the window. This is the primary reason the Winit backend exists.
#![allow(unused)]
fn main() {
let win = Window::new(800, 600);
let handle = win.get_window_handle(); // NSView*, HWND, X11 Window, or wl_surface*
let display = win.get_display_handle(); // X11 Display* or wl_display* (null elsewhere)
match win.get_window_handle_type() {
// One of the LAUFEY_WINDOW_HANDLE_* constants: AppKit, Win32, X11, or Wayland.
handle_type => { /* create a rendering surface for this platform */ }
}
}
The window handle, the display handle, and the type constant together provide
everything a library such as raw-window-handle needs to build a rendering
surface. The CEF and WebView backends own and render into their windows
themselves, so they do not expose these handles.
Dock / taskbar
laufey can badge the application icon, request the user’s attention, and — on macOS — drive the dock menu, the icon’s visibility, and a reopen callback. These are free functions rather than window methods, because the dock is application-scoped on macOS, while on Windows and Linux the equivalent operations act on the currently focused window’s taskbar button.
#![allow(unused)]
fn main() {
use laufey::DockBounceType;
laufey::set_dock_badge(Some("3")); // pass None to clear the badge
laufey::bounce_dock(DockBounceType::Critical);
laufey::on_dock_reopen(|has_visible_windows| {
// On macOS, the user clicked the dock icon while no windows were open.
});
}
On macOS the badge is a native red overlay drawn on the dock tile, and on
Windows it is a small overlay icon composited onto the taskbar button. Linux has
no icon overlay, so every backend falls back to prefixing the focused window’s
title with "(N) ", the convention used by applications such as Slack, Discord,
and Telegram; taskbars and window-manager overviews surface that title.
Requesting attention bounces the dock icon on macOS, flashes the taskbar button
on Windows, and sets the window’s urgency hint on Linux. The dock menu, the
ability to hide the dock icon, and the reopen callback exist only on macOS.
Tray / status bar
A tray icon is a persistent icon in the operating system’s status area: the menu
bar on macOS, the system tray on Windows, and the AppIndicator area on Linux.
Each icon has an image, a tooltip, a right-click menu, and click handlers.
TrayIcon is a builder; you must keep the returned value alive for the icon to
remain visible.
#![allow(unused)]
fn main() {
use laufey::{MenuItem, TrayIcon};
let tray = TrayIcon::new()
.icon(include_bytes!("icon.png"))
.icon_dark(include_bytes!("icon-dark.png")) // optional dark-mode variant
.tooltip("My App")
.menu(&[MenuItem::Role { role: "quit".into() }], |id| println!("{id}"))
.on_click(|| println!("clicked"))
.on_double_click(|| println!("double clicked"));
// The icon's bounds let you anchor a popover panel beneath it.
let bounds = tray.get_bounds(); // Option<(x, y, width, height)>
}
When you provide both a light and a dark icon, the backend watches the system
appearance and swaps between them live: it observes
AppleInterfaceThemeChangedNotification on macOS, the WM_SETTINGCHANGE
message together with the AppsUseLightTheme setting on Windows, and polls once
per event-loop tick on Winit. On Linux, AppIndicator renders the icon through
the desktop theme and does not deliver click or double-click events, and the
StatusNotifierItem specification has no tooltip, so click handlers, tooltips,
and dark-mode swapping have no effect there. The CEF backend also uses
AppIndicator on Linux, so a tray icon does not require a browser window.
Notifications
laufey can post system notifications. The options mirror a subset of the Web Notifications API: a title and body, an icon, a tag that replaces an earlier notification carrying the same tag, a silent flag, a require-interaction flag, and action buttons. Notifications are application-scoped.
#![allow(unused)]
fn main() {
use laufey::Notification;
let handle = Notification::new("Build finished")
.body("3 warnings")
.icon(include_bytes!("icon.png").to_vec())
.tag("build")
.action("rebuild", "Rebuild")
.on_event(|event| println!("{event:?}")) // shown, clicked, closed, or action
.show();
handle.close();
}
The implementation differs by platform. macOS posts through
NSUserNotification, which does not require authorization to post; the modern
UNUserNotificationCenter is used only for the permission prompt described in
permissions.md. Windows posts a Shell_NotifyIcon balloon,
which Windows 10 and 11 render as a system toast, but those balloons cannot show
action buttons. Linux shells out to notify-send, which is fire-and-forget, so
only the synthetic shown and closed events are reported. The Winit backend uses
notify-rust and reports show, close, and a synthetic shown event; use the CEF
or WebView backend when you need click or action callbacks.
Clipboard
laufey can read and write the operating system’s clipboard as plain text,
mirroring the web navigator.clipboard.readText() / writeText() surface. The
two operations are application-scoped free functions:
#![allow(unused)]
fn main() {
// Write text to the system clipboard.
laufey::write_clipboard_text("hello from laufey");
// Read it back. Returns `None` if the clipboard is empty, holds no text, or
// the backend doesn't support clipboard access.
if let Some(text) = laufey::read_clipboard_text() {
println!("clipboard: {text}");
}
}
Passing an empty string to write_clipboard_text clears the clipboard. Both
functions must be called on the UI thread.
The implementation differs by platform. macOS uses NSPasteboard
(UIPasteboard on iOS), Windows uses the Win32 clipboard with the
CF_UNICODETEXT format, and Linux uses the GTK clipboard (the CLIPBOARD
selection) on the CEF and WebView backends.
The engine-free Winit backend has no web engine bundled, so it shells out to the
platform’s standard clipboard tools instead — pbcopy / pbpaste on macOS,
clip / Get-Clipboard on Windows, and wl-clipboard (falling back to
xclip) on Linux. These are present on a default desktop install of each
platform; if the Linux tools are missing, reads return None and writes are a
no-op.
Permissions
laufey lets you query or request the operating system’s authorization for a capability. The only capability today is notifications. The status set mirrors the Web Permissions API: granted, denied, prompt, and unsupported.
#![allow(unused)]
fn main() {
use laufey::{PermissionKind, PermissionStatus};
laufey::request_permission(PermissionKind::Notifications, |status| {
if status == PermissionStatus::Granted {
// The capability is authorized.
}
});
}
query_permission reads the current status without prompting the user.
request_permission shows the system prompt only when the status is prompt;
once the user has decided, the operating system returns the cached decision
rather than prompting again. Both callbacks run on the user-interface thread.
On macOS all backends route through UNUserNotificationCenter. A process that
is not bundled — one with no CFBundleIdentifier, or a binary that does not
live inside an .app — reports unsupported rather than denied, so that an
embedder can distinguish “the user declined” from “this environment cannot be
authorized at all.” An application that packages its own .app sets its own
bundle identifier and entitlements; laufey hard-codes none of its own. Windows
(Shell_NotifyIcon) and Linux (notify-send) have no permission model, so both
calls report granted immediately.
Packaging & distribution
laufey stops at the backend and the runtime. A build produces a backend executable and your runtime shared library; turning that into something you can ship — and keeping it up to date — is the responsibility of the embedder that wraps laufey, not of laufey itself.
Bundling
laufey does not produce an application bundle. The embedder decides how the
backend executable and the runtime library are laid out and packaged: a macOS
.app, a Windows installer or directory, a Linux .deb/AppImage, and so on.
This is by design — laufey stays unopinionated so that a host such as the
deno desktop tooling can own the packaging
format end to end.
One detail does reach into laufey on macOS. Several features —
notifications and permissions — depend on
the process running inside a real .app with a CFBundleIdentifier. An
unbundled binary (run straight from target/, or the synthetic bundle
cargo run produces) reports unsupported rather than failing, so those
features come to life only once the embedder has bundled the app. laufey
hard-codes no bundle identifier of its own, leaving the embedder free to set its
own identity.
Code signing & notarization
laufey does not sign or notarize anything. Code signing (macOS Developer ID + notarization, Windows Authenticode) is applied by the embedder to the final bundle, using its own certificates and entitlements. Because laufey carries no bundle identifier and no embedded entitlements, the embedder controls the app’s identity completely, which is what lets the system authorization prompts target the embedder rather than laufey.
Updates
laufey has no built-in updater. Shipping new versions — full replacement or binary-diff patch updates — is left to the embedder’s distribution channel. The backend and runtime are ordinary files, so any update mechanism the host already uses applies without special support from laufey.
Building
A Makefile drives the build. make help lists every target.
Prerequisites
- Rust (stable)
cmakeandninja- macOS:
brew install llvm(forlibclang) - Linux: GTK + X dev packages
(
libgtk-3-dev libxkbcommon-dev libxrandr-dev libxrender-dev libxtst-dev) - Windows: Visual Studio (MSVC) + LLVM; build from a
vcvars64shell
make check-deps verifies the base tools.
Backends
make cef # CEF backend (downloads + builds the CEF dll wrapper first)
make webview # system WebView backend (WebKitGTK / WebView2)
make winit # windowing-only backend, no web engine
make all # everything
make cef runs make cef-deps, which downloads the pinned CEF build into
vendor/cef/<cef-version>/<cef-platform>/ and builds libcef_dll_wrapper. The
exact CEF build is pinned at the top of the Makefile (CEF_FULL_VERSION).
Changing the pin selects a new cache directory; make clean-cef-vendor removes
every downloaded CEF build.
Host OS/arch and the matching CEF archive are detected automatically.
Runtimes
make runtimes # builds the hello + ddcore example runtimes
A runtime is a shared library linked against the capi crate; a backend loads
it at startup (see architecture.md).
Formatting, linting, tests
make fmt # cargo fmt + deno fmt + clang-format
make fmt-check
make lint # cargo clippy + deno lint
cargo test -p laufey --lib
Output
Backends build under each backend dir’s build/ (cef/build, webview/build).
make clean removes build artifacts; make clean-cef-vendor drops the
downloaded CEF.
End-to-end testing strategy
This document describes how laufey can automatically test its native chrome and windowing surface — menus, tray icons, notifications, dialogs, clipboard, window geometry/state, input events, and the JavaScript bridge — across every backend (CEF, WebView, Winit) on Linux, macOS, and Windows.
It is a design/contributor document, not a user reference. It records the verification techniques, the empirical findings that back them, a full coverage matrix over the C ABI surface, and a phased rollout.
Status: implemented for all three backends.
examples/native_e2e(the Layer-0 battery + menu/tray click round-trips via thetest_click_menu_itemhook) andexamples/native_e2e_driver(the Linux D-Bus observer) run under Winit, WebView, and CEF viascripts/native-e2e-run.sh. Thenative-e2eCI job gates macOS for all three backends (both native-chrome codebases, end to end), plus winit/Windows and cef/Linux. Some backend × OS combos are excluded as known headless-CI backend limitations (follow-ups, not harness bugs): winit/Linux (the winit backend doesn’t support Linux menus and panics building a muda menu with no GTK init), webview/Linux (WebKitGTK/Xlib isn’t thread-safe under the worker-thread runtime), and cef|webview/Windows (CEF dist extraction / WebView2 run flakiness). Thetest_click_menu_itemhook (§8) is implemented for the Winit backend (Rust) and the C++backend-commonshared by CEF/WebView. The macOS self-accessibility approach (§7.2) is verified standalone but not yet embedded (it needs the backend’s main thread — see§8). Implementing the hook exposed and fixed a pre-existing self-deadlock in the Winit menu-callback registration.
1. Goals and non-goals
Goals
- Automatically verify, in CI, that laufey’s native surface actually works — not just that the C ABI accepts a call, but that the OS registered/rendered the widget and that user-driven callbacks round-trip back to app code.
- Cover all backends, because native chrome has two independent
implementations (C++
backend-commonfor CEF/WebView; Rustbackend-winit-commonfor Winit) and four web engines. - Maximize the fraction of the surface that runs as a blocking PR gate on stock GitHub-hosted runners (no self-hosted infra, no manual permission setup).
Non-goals
- Pixel-perfect visual regression. Screenshot diffing is high-flake and low-diagnostic; it is not part of the gate. (A last-resort visual smoke on notifications is acceptable nightly.)
- Simulating real hardware user input where a cheaper deterministic path exists. We drive callbacks through the same dispatch code a real event would, not by injecting OS-level mouse/keyboard events, except where noted.
2. Why this is hard (and where it isn’t)
The existing harness, examples/cef_e2e, exercises the CEF web bridge
(bindings, execute_js, navigation) but explicitly punts on everything that
lives outside the webview:
Doesn’t drive dialogs (alert/confirm/prompt) because those need either real OS input or a backend-side test stub — neither exists today.
Native chrome is OS-owned and has no return value to assert against — a menu or a tray icon is “somewhere on the screen”, owned by the window server, the shell, or another process. That is the genuinely hard part.
The key insight of this document is that the difficulty is wildly asymmetric, and that most of the surface is not actually hard:
- A large fraction of the API has direct readback (
set_window_size→get_window_size,write_clipboard_text→read_clipboard_text). This is the cheapest, most reliable category and needs none of the machinery below. - The truly OS-owned chrome (tray/menu/notifications) turns out to be introspectable without pixels on every platform, via a different mechanism each: D-Bus on Linux, the Accessibility API on macOS, UI Automation on Windows.
- Only genuinely modal/outward-facing surfaces (dialogs, devtools, external browser launch) resist automation and are relegated to nightly.
3. Backend and surface landscape
3.1 Backends are not interchangeable
Runtimes are backend-agnostic cdylibs; a backend loads one via
--runtime <path> or LAUFEY_RUNTIME_PATH. That means one test runtime can
be driven by every backend binary. But the backends differ in what they
implement:
| Backend | Web engine | Native-chrome impl | OSes |
|---|---|---|---|
| CEF | Chromium (multiprocess) | C++ backend-common | L/mac/win |
| WebView | WKWebView / WebView2 / WebKitGTK (per-OS!) | C++ backend-common | L/mac/win |
| Winit | none | Rust backend-winit-common (tray-icon, muda) | L/mac/win |
| Servo | Servo (branch) | — | deferred |
| iOS | WKWebView (iOS) | subset | deferred |
Consequences:
- There are two native-chrome codebases. Running the tray/menu/clipboard
suite under both a
backend-commonbackend and Winit validates two separate implementations and catches divergence. This is the real payoff of “test all backends” — not redundancy. - WebView is three different web engines by OS, so its web-layer tests genuinely differ per platform.
- Winit has no web engine:
navigate,execute_js,set_page_load_handler, andregister_scheme_handlerareNone. Winit leaves ~68 API fields unimplemented in total. Capability probing (§6) is therefore mandatory.
3.2 The full C ABI surface
Every function pointer in laufey_backend_api_t is a capability to cover. They
group as:
- Windowing / state: create, size, position, resizable, always-on-top, visibility (show/hide), opacity, window flags (frameless, transparent, transparent-titlebar, hidden, no-activate), handles.
- Window & input events: resize, move, focus, close-requested, mouse click/move, wheel, cursor enter/leave, keyboard.
- Web bridge (CEF/WebView only): navigate, execute_js, JS bindings / namespace / callbacks, page-load, custom scheme handlers, devtools.
- Native chrome: application menu, context menu, tray (icon/tooltip/menu/ click/double-click/dark-icon/bounds), dock/taskbar (badge/bounce/menu/ visibility/reopen), notifications, dialogs (alert/confirm/prompt/file).
- System integration: clipboard read/write, permissions (query/request).
4. Verification techniques
Ordered cheapest → hardest. Each capability maps to one (or a combination).
A. Direct state readback — cheapest, most reliable, all-platform gate
Set a property, read it back from the real OS object via the backend’s own getter. No display-server introspection needed.
- Window:
set_window_size/get_window_size,set_window_position/get_window_position,set_resizable/is_resizable,set_always_on_top/is_always_on_top,show/hide/is_visible,set_window_opacity/get_window_opacity. - Clipboard:
write_clipboard_text→read_clipboard_text(round-trips through the real OS clipboard). - Handles:
get_window_handle/get_display_handle/get_window_handle_type(assert non-null and correct type enum per platform). - Permissions:
query_permission. - Tray geometry:
get_tray_icon_bounds.
B. Event injection → callback round-trip
Drive a callback by calling the corresponding setter and asserting the handler fires with the right arguments. No OS input required.
set_resize_handler←set_window_size;set_move_handler←set_window_position;set_focused_handler←focus/show;set_close_requested_handler← programmatic close.- Menu / tray clicks via the platform’s non-modal invoke primitive (
§5). set_page_load_handler←navigate(CEF/WebView).
C. Custom scheme / IPC
Navigate to a custom-scheme URL and assert the registered handler served the expected bytes. Same shape as the existing binding round-trip.
D. OS-observer introspection — for chrome with no getter
The three platform mechanisms (§7): Linux D-Bus, macOS self-Accessibility,
Windows UI Automation. Covers tray, menu structure as the OS sees it,
notifications, window title, decorations, dock.
E. Raw input events
Mouse/keyboard/wheel/cursor handlers. Best driven by a backend test-inject
hook that posts a synthetic event down the same path (deterministic), rather
than OS-level input injection (flaky). Part of the Layer-0 hook (§8).
F. Modal / outward-facing — nightly
show_dialog (alert/confirm/prompt/file), request_permission,
open_devtools, bounce_dock, external-browser open. Modal dialogs block the
main thread (see the macOS modal trap in §7.2), so they need either a backend
auto-answer stub or an external AX/UIA driver on a separate thread.
5. Per-platform non-modal click primitives
For Layer-0 click round-trips we invoke an item through the same dispatch a real click uses, without entering a modal tracking loop:
| Platform | Primitive | Fires |
|---|---|---|
| macOS | [NSMenu performActionForItem:idx] | LaufeyCommonMenuTarget menuItemClicked: (menu_mac.mm) |
| Linux | gtk_menu_item_activate(item) | OnGtkMenuItemActivate (menu_linux.cc) |
| Windows | post WM_COMMAND with the item’s command id | WM_COMMAND handler (tray_win.cc / menu) |
macOS note: AXPress on a status item opens the menu modally on the main
thread and deadlocks in-process driving — do not use AX to invoke;
performActionForItem is the correct primitive (verified, §7.2).
6. Capability probing (mandatory)
Because backends implement different subsets, a test must distinguish
“unsupported here” (→ N/A) from “supported but broken” (→ FAIL). Probe via
documented signals:
- Tray:
create_tray_icon()returns0when unsupported. - Any capability whose backend fn pointer is
None: the capi Rust wrapper returnsOption::None/ no-ops — the runtime treats that asN/A. - Web capabilities are absent on Winit (
navigate/execute_js/… areNone) → web/JS/scheme/devtools assertions are skipped on Winit.
Each assertion is tagged with the capability it requires; the harness emits
[e2e] PASS <name>, [e2e] FAIL <name>, or [e2e] N/A <name> and exits
non-zero only on FAIL. This lets one runtime binary be valid across all
backends.
7. The observers
7.1 Linux — D-Bus watcher/observer (written, compiles)
On Linux, both native-chrome implementations expose the tray over the
freedesktop StatusNotifierItem spec and the menu over
com.canonical.dbusmenu:
- CEF/WebView: libayatana-appindicator (
backend-common/src/tray_linux.cc). - Winit: the
tray-iconcrate’s internal StatusNotifier logic.
So a single D-Bus driver validates both. The driver is the desktop shell:
it owns org.kde.StatusNotifierWatcher (with
IsStatusNotifierHostRegistered = true, without which libappindicator silently
falls back to legacy GtkStatusIcon/XEmbed and never touches D-Bus), owns a stub
org.freedesktop.Notifications to capture Notify payloads, spawns the backend
- runtime, and then introspects.
Run line (works for any backend binary):
xvfb-run -a dbus-run-session -- sni-driver <backend-bin> --runtime libnative_e2e.so
Key facts baked into the driver:
- libayatana passes the item’s object path to
RegisterStatusNotifierItem; the bus name is the message sender (#[zbus(header)] hdr → hdr.sender()), not the argument. (KDE-style apps pass a bus name instead — handle both.) com.canonical.dbusmenu.GetLayout(0, -1, [])returns(u32 revision, (i32 id, a{sv} props, av children)); children are variants wrapping the recursive struct — walk manually.- A menu click is
AboutToShow(id)thenEvent(id, "clicked", <variant "">, <timestamp u32>), which fires the app’slaufey_menu_click_fn. - Icon set via a
/tmpPNG path surfaces asIconThemePath, notIconPixmap; Linux tray tooltip and left-click are no-ops — don’t assert them.
The driver lives at examples/native_e2e/driver (Rust, zbus v5 with the
tokio feature). Skeleton of the watcher interface:
#![allow(unused)]
fn main() {
#[interface(name = "org.kde.StatusNotifierWatcher")]
impl Watcher {
async fn register_status_notifier_item(
&self, service: &str, #[zbus(header)] hdr: Header<'_>,
) {
let sender = hdr.sender().map(|s| s.to_string()).unwrap_or_default();
let (bus_name, path) = if service.starts_with('/') {
(sender, service.to_string()) // ayatana / Winit tray-icon
} else {
(service.to_string(), "/StatusNotifierItem".to_string()) // KDE-style
};
self.tx.send((bus_name, path)).ok();
}
#[zbus(property)] async fn is_status_notifier_host_registered(&self) -> bool { true }
#[zbus(property)] async fn registered_status_notifier_items(&self) -> Vec<String> { /* ... */ }
#[zbus(property)] async fn protocol_version(&self) -> i32 { 0 }
}
}
7.2 macOS — self-Accessibility (empirically verified, no permission)
macOS has no protocol boundary; the chrome is live AppKit objects. The
Accessibility API reaches them, and — verified on macOS 15.5 with
AXIsProcessTrusted() == false (i.e. no TCC permission granted) — a process
can read its own tree:
AXUIElementCreateApplication(getpid())
AXMenuBar -> AXError 0, full app menu
AXExtrasMenuBar -> AXError 0, the app's own NSStatusItem + its menu items
So macOS menu and tray structure are verifiable on hosted CI with zero
permission setup — no XCUITest, no self-hosted runner, no TCC.db surgery
(which is SIP-protected and unavailable on hosted runners anyway). The click
half uses NSMenu.performActionForItem(at:) in-process (verified to fire the
target-action, no permission, no modal loop).
This runs in-process inside the test runtime (a small AX verifier invoked after the runtime builds its menus), so it is backend- and engine-agnostic.
Limitation: true external user-input simulation still needs TCC/XCUITest, but structure + callback wiring — which is what we care about — does not.
7.3 Windows — UI Automation
UIA has no permission gate; any process can inspect any UI on the session.
- Menus:
ControlType.Menu/MenuItemvia FlaUI (.NET/UIA3 — the maintained choice; WinAppDriver has had no release since 2020). Enumerate andInvoke. - Tray: icons live in Explorer (
Shell_TrayWnd→ notification-area toolbar +NotifyIconOverflowWindow). Enumerate by name (= tooltip). Win11 moved most icons into the overflow flyout and changed the shell toolbar model, so enumeration is brittle → prefer the Layer-0 callback per-PR and treat tray scraping as nightly. - Toasts: UIA over the toast / Action Center — nightly.
8. Layer 0 — the in-process test hook
A small, test-only extension to laufey_backend_api_t that proves laufey’s own
plumbing (template parse → native build → callback dispatch → id routing) on
all backends cheaply and deterministically — essentially what Electron’s own
spec suite does. Appending to the end of the struct is ABI-safe because every
backend memsets its api table (unimplemented hooks stay NULL → the capi
wrapper returns false/None → the runtime reports N/A); the API version is
bumped alongside (29 → 30).
Implemented (API 30):
// Synthesizes a click on the menu/tray item with id `item_id` by invoking the
// same on_click dispatch a real click uses (looks the handler up by id in the
// backend's shared click store and calls it). Returns true if an item with
// that id was registered and its handler ran. Runs on the caller's thread — no
// main-thread UI access needed — so it works from the worker-thread runtime.
bool (*test_click_menu_item)(void* backend_data, const char* item_id);
Implemented for both native-chrome codebases, so every backend has it:
- Winit (
backend-winit-common):dispatch_menu_click_by_idreuses the exact pathpoll_menu_eventsuses for a real mudaMenuEvent. - CEF + WebView (C++
backend-common): a shared click registry (test_hooks.cc:RegisterMenuClick/TestClickMenuItem) that the menu builders (menu_mac.mm,menu_linux.cc,tray_win.cc) populate; each backend’s api table pointstest_click_menu_itemat it.
The capi exposes laufey::test_click_menu_item(item_id). All three backends run
the same runtime green (both app-menu and tray-menu click round-trips PASS).
Doing this surfaced and fixed a pre-existing self-deadlock in Winit:
register_menu_callbacks held the click-store mutex while recursing into
submenus (std Mutex is not reentrant), freezing the main thread on any menu
containing a submenu.
Not yet added (future hooks, same append-and-N/A pattern):
// Serialize the menu the backend ACTUALLY built, for template->native readback.
laufey_value_t* (*test_dump_menu)(void* backend_data, int surface, uint32_t id);
// Post a synthetic input event for the mouse/keyboard/wheel/cursor handlers.
bool (*test_inject_input)(void* backend_data, uint32_t window_id,
const laufey_test_input_t* event);
Note the contrast with macOS self-AX (§7.2): reading the OS’s view of a widget
needs the backend’s main thread, which the worker-thread runtime can’t reach
(a dispatch_sync to the main queue deadlocks against the backend event loop) —
so structure checks must live behind a backend hook too, whereas the click hook
above only touches an in-process mutex and works from any thread.
9. Full coverage matrix
Rows are capability groups; each cell is per-backend. Every ✅ is additionally
per-OS (Linux/macOS/Windows); WebView’s web-layer cells differ by engine.
| Capability | Technique | CEF | WebView | Winit | Gate |
|---|---|---|---|---|---|
| Window geometry/state/opacity readback | A | ✅ | ✅ | ✅ (Rust) | ✅ |
| Window lifecycle events | B | ✅ | ✅ | ✅ | ✅ |
| Clipboard round-trip | A | ✅ | ✅ | ✅ | ✅ |
| Window handles / types | A | ✅ | ✅ | ✅ | ✅ |
| Application / context menu | D + B | ✅ | ✅ | ✅ (muda) | ✅ |
| Tray icon / menu / click | D + B | ✅ | ✅ | ✅ (tray-icon) | ✅ (win tray nightly) |
| Notifications payload | D | ✅ | ✅ | probe | Linux ✅, else nightly |
| Dock / taskbar | A/D/F | ✅ | ✅ | probe | partial |
| Raw mouse/keyboard/wheel events | E | ✅ | ✅ | ✅ | ✅ (with hook) |
| Web: bindings/execute_js/navigate/load | B/C/E | ✅ | ✅ | N/A | ✅ (CEF/WebView) |
| Custom scheme handlers | C | ✅ | ✅ | N/A | ✅ (CEF/WebView) |
| DevTools | F | ✅ | partial | N/A | nightly |
| Dialogs (alert/confirm/prompt/file) | F | ⚠️ | ⚠️ | ⚠️ | nightly |
Net: ~90% of the C ABI is a hosted-CI PR gate across all backends; only modal / outward-facing surfaces are nightly.
10. CI architecture
Today CI builds only Winit + lint + a capi unit/doc test. To test all
backends:
-
Build jobs for CEF and WebView per OS. CEF is expensive (downloads/builds ~GBs) — cache aggressively; consider running CEF nightly while WebView + Winit gate per-PR.
-
Test matrix
backend ∈ {cef, webview, winit} × os ∈ {linux, macos, windows}, each launching the shared runtime under the right wrapper:# Linux (covers cef/webview libappindicator AND winit tray-icon): xvfb-run -a dbus-run-session -- sni-driver <backend-bin> --runtime libnative_e2e.so # macOS (self-AX + readback, in-process, no permission): <backend-bin> --runtime libnative_e2e.dylib # Windows (readback + FlaUI attach): <backend-bin> --runtime native_e2e.dll
Because the runtime is written once and the observers are backend-agnostic, the incremental cost of “all backends” is mostly build time and matrix legs, not new test code.
11. Rollout
- Layer 0 battery — the capability-probing
native_e2eruntime: readback + event-callback + clipboard + scheme + menu/tray assertions, each tagged with its required capability and thePASS/FAIL/N/Aprotocol. Highest ROI, covers recent code (opacity, clipboard, app_id). ~2–3 days. - Backend-launch matrix driving that runtime under CEF/WebView/Winit; reuse
sni-driveras the Linux wrapper for all three. ~2 days. - Layer 1 Linux — land
sni-driver+e2e-linux-nativejob. Flagship. ~2–3 days (driver already compiles). - Layer 1 macOS — embed the self-AX verifier; add to the macOS leg. ~2 days.
- Layer 1 Windows — FlaUI menu suite; tray scraping nightly. ~2–3 days.
- CI — add CEF/WebView build jobs (cache CEF; CEF nightly if needed).
- Nightly — dialogs/modal/outward-facing; later Servo branch and iOS.
12. Open questions / spikes
- Whether the CI libayatana version registers the item at
/StatusNotifierItemvs/org/ayatana/NotificationItem/*— the sender-based capture handles both, but assertions on the path should not hard-code it. - laufey’s real macOS
NSStatusItemuses a button image, not a title, so its AX bar item may have noAXTitle— assert on the menu items (which do have titles) rather than the bar item. - Winit notification / dock capability coverage (probe at runtime; several
fields are
None). - Dialog auto-answer: backend stub vs external AX/UIA driver on a side thread.
13. Verified artifacts
- Linux D-Bus driver: written,
cargo checkpasses againstzbus5.16 (§7.1). - macOS self-AX read of
AXMenuBar/AXExtrasMenuBarandNSMenu.performActionForItem(at:): empirically confirmed on macOS 15.5 with no accessibility permission granted (§7.2).