Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

laufey

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

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

DirectoryEngineLanguageWindow ownership
cef/Chromium Embedded FrameworkC++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:

  1. Define a C callback type (laufey_keyboard_event_fn, laufey_mouse_click_fn)
  2. Add a set_*_handler(backend_data, callback, user_data) function pointer to the API struct
  3. Backend stores the callback+user_data behind a mutex
  4. 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:

AreamacOSWindowsLinux
Notificationsnotifications_mac.mm (UN)notifications_win.cc (NIIF)notifications_linux.cc (notify-send)
Dialogsdialog_mac.mm (NSAlert)dialog_win.cc (MessageBoxW + PowerShell prompt)dialog_linux.cc (gtk_message_dialog)
Permissionspermissions_mac.mm (UN auth)permissions_stub.cc (always granted)permissions_stub.cc (always granted)
Dockdock_mac.mm (badge / bounce / visible / dock menu storage / reopen handler)per-backend (FlashWindowEx) + title_badge.cc for the badgeper-backend (gtk_window_set_urgency_hint) + title_badge.cc for the badge
Key mappingkeymap_mac.mm (NSEvent → W3C)keymap_vk.cc (VK → W3C; CEF uses on every platform)keymap_gdk.cc (GDK → W3C)
App / context menumenu_mac.mm (NSMenu)capi/include/win32_menu.h (HMENU + SetMenu / TrackPopupMenu)menu_linux.cc (GtkMenu / GtkMenuBar)
Tray iconstray_mac.mm (NSStatusItem)tray_win.cc (Shell_NotifyIcon + WIC + HMENU)tray_linux.cc (libappindicator + g_idle_add)
Option parsingparse_options.cc (compiled on every platform; bridges laufey_value_t → plain structs)
Title-prefix badge bookkeepingtitle_badge.cc (ApplyTitlePrefixBadge — used by CEF Win+Linux and webview Win+Linux for Dock-badge fallback)

Notes:

  • win32_menu.h is the older shared header-only library for Windows menu construction; it predates backend-common and stays as-is. The two patterns coexist — both backends use win32_menu for Windows app/context menus, and backend-common for 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) vs gtk_window_set_title(GtkWindow*) vs CEF’s CefWindow::SetTitle), but the saved-titles bookkeeping and "(badge) " + title string concatenation are unified in laufey_common::ApplyTitlePrefixBadge.
  • FlashWindow (Windows) and gtk_window_set_urgency_hint (Linux) for bounce_dock remain 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:

  • BackendAccess trait: each backend implements this to provide access to its CommonState, event loop proxy, and event type mapping.
  • define_common_backend_fns! macro: generates the unsafe extern "C" functions for all common operations (title, size, position, visibility, event handlers, etc.).
  • fill_common_api! macro: wires those generated functions into a LaufeyBackendApi struct.
  • CommonState: holds pending window mutations (Mutex<Option<T>>) and event handler callbacks.
  • handle_common_event(): processes CommonEvent variants against a winit Window.

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:

  1. Store the desired value in a Mutex<Option<T>> on CommonState
  2. Send an event via the winit EventLoopProxy
  3. 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:

PlatformTechniqueFile
macOS[NSEvent addLocalMonitorForEventsMatchingMask:]cef/src/main_mac.mm
WindowsWM_*BUTTON* messages in WindowProc (via CefWindow::GetWindowHandle() + subclassing)cef/src/main_win.cc (TODO)
LinuxGTK 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:

PlatformKeyboardMouse
macOS (webview_macos.mm)NSEvent addLocalMonitorForEventsMatchingMask: for key eventsSame mechanism for mouse events
Windows (webview_windows.cc)WM_KEYDOWN / WM_KEYUP in WindowProcWM_*BUTTON* in WindowProc
Linux (webview_linux.cc)GTK key-press-event / key-release-event signalsGTK 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() in backend-winit-common
  • CEF: CefKeyCodeToString() / CefKeyCodeToCode() in cef/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/XBUTTON2 for 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 / CefListValue directly
  • Webview: uses a custom Value class 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)SignatureRole
laufey_runtime_initint(const laufey_backend_api_t* api)Backend hands the runtime the API table. Stash it; return 0 on success.
laufey_runtime_startint(void)Run application setup (create windows, register handlers). Returns when ready.
laufey_runtime_shutdownvoid(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 lifecyclecreate_window, create_window_ex (style flags, see LAUFEY_WINDOW_FLAG_*, including LAUFEY_WINDOW_FLAG_TRANSPARENT for 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 interopset_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 handlersset_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 handlesget_window_handle, get_display_handle, get_window_handle_type (for GPU surface creation).
  • Menusset_application_menu, show_context_menu, open_devtools.
  • Dialogsshow_dialog, string_free.
  • Dock / taskbarset_dock_badge, bounce_dock, set_dock_menu, set_dock_visible, set_dock_reopen_handler.
  • Traycreate_tray_icon, destroy_tray_icon, set_tray_icon(_dark), set_tray_tooltip, set_tray_menu, click handlers, get_tray_icon_bounds.
  • Notificationsshow_notification, close_notification.
  • Permissionsquery_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 with value_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 take backend_data), then value_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

  1. The runtime exposes a namespace in the page (set_js_namespace, default "Laufey") and registers set_js_call_handler.
  2. Page JS calls Laufey.someMethod(args…); the backend invokes the handler with a call_id, the method name, and the arguments as a laufey_value_t list.
  3. 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.

  1. 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.
  2. When the webview requests <scheme>://…, the backend invokes handler with request metadata (method, URL, headers) and an opaque laufey_scheme_exchange_t*. Headers use a flat name\0value\0…\0 encoding (headers_len counts every terminating NUL).
  3. 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, and scheme_response_finish to 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).

BackendEngineProcess modelBundledJS bridge
CEFChromium 144multi-processyesyes
WebViewsystem nativesinglenoyes
Winitnonesinglen/ano

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 + root UIViewController hosting a WKWebView.
  • 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 in 0.0..=1.0, where 1.0 is fully opaque (the default), like CSS opacity on the whole window. It is a live setter you can animate at runtime. The web backends implement it on every desktop platform (macOS NSWindow.alphaValue, Windows layered-window alpha, Linux gtk_widget_set_opacity). The Winit backend has no opacity API, so the call is a no-op there and get_opacity returns 1.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. a transparent root background) shows the desktop through it. This must be chosen at creation time and is commonly paired with frameless.

    #![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)
  • cmake and ninja
  • macOS: brew install llvm (for libclang)
  • Linux: GTK + X dev packages (libgtk-3-dev libxkbcommon-dev libxrandr-dev libxrender-dev libxtst-dev)
  • Windows: Visual Studio (MSVC) + LLVM; build from a vcvars64 shell

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 the test_click_menu_item hook) and examples/native_e2e_driver (the Linux D-Bus observer) run under Winit, WebView, and CEF via scripts/native-e2e-run.sh. The native-e2e CI 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). The test_click_menu_item hook (§8) is implemented for the Winit backend (Rust) and the C++ backend-common shared 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-common for CEF/WebView; Rust backend-winit-common for 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_sizeget_window_size, write_clipboard_textread_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:

BackendWeb engineNative-chrome implOSes
CEFChromium (multiprocess)C++ backend-commonL/mac/win
WebViewWKWebView / WebView2 / WebKitGTK (per-OS!)C++ backend-commonL/mac/win
WinitnoneRust backend-winit-common (tray-icon, muda)L/mac/win
ServoServo (branch)deferred
iOSWKWebView (iOS)subsetdeferred

Consequences:

  • There are two native-chrome codebases. Running the tray/menu/clipboard suite under both a backend-common backend 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, and register_scheme_handler are None. 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_textread_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_handlerset_window_size; set_move_handlerset_window_position; set_focused_handlerfocus/show; set_close_requested_handler ← programmatic close.
  • Menu / tray clicks via the platform’s non-modal invoke primitive (§5).
  • set_page_load_handlernavigate (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:

PlatformPrimitiveFires
macOS[NSMenu performActionForItem:idx]LaufeyCommonMenuTarget menuItemClicked: (menu_mac.mm)
Linuxgtk_menu_item_activate(item)OnGtkMenuItemActivate (menu_linux.cc)
Windowspost WM_COMMAND with the item’s command idWM_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() returns 0 when unsupported.
  • Any capability whose backend fn pointer is None: the capi Rust wrapper returns Option::None / no-ops — the runtime treats that as N/A.
  • Web capabilities are absent on Winit (navigate/execute_js/… are None) → 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-icon crate’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) then Event(id, "clicked", <variant "">, <timestamp u32>), which fires the app’s laufey_menu_click_fn.
  • Icon set via a /tmp PNG path surfaces as IconThemePath, not IconPixmap; 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/MenuItem via FlaUI (.NET/UIA3 — the maintained choice; WinAppDriver has had no release since 2020). Enumerate and Invoke.
  • 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_id reuses the exact path poll_menu_events uses for a real muda MenuEvent.
  • 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 points test_click_menu_item at 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.

CapabilityTechniqueCEFWebViewWinitGate
Window geometry/state/opacity readbackA✅ (Rust)
Window lifecycle eventsB
Clipboard round-tripA
Window handles / typesA
Application / context menuD + B✅ (muda)
Tray icon / menu / clickD + B✅ (tray-icon)✅ (win tray nightly)
Notifications payloadDprobeLinux ✅, else nightly
Dock / taskbarA/D/Fprobepartial
Raw mouse/keyboard/wheel eventsE✅ (with hook)
Web: bindings/execute_js/navigate/loadB/C/EN/A✅ (CEF/WebView)
Custom scheme handlersCN/A✅ (CEF/WebView)
DevToolsFpartialN/Anightly
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:

  1. 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.

  2. 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

  1. Layer 0 battery — the capability-probing native_e2e runtime: readback + event-callback + clipboard + scheme + menu/tray assertions, each tagged with its required capability and the PASS/FAIL/N/A protocol. Highest ROI, covers recent code (opacity, clipboard, app_id). ~2–3 days.
  2. Backend-launch matrix driving that runtime under CEF/WebView/Winit; reuse sni-driver as the Linux wrapper for all three. ~2 days.
  3. Layer 1 Linux — land sni-driver + e2e-linux-native job. Flagship. ~2–3 days (driver already compiles).
  4. Layer 1 macOS — embed the self-AX verifier; add to the macOS leg. ~2 days.
  5. Layer 1 Windows — FlaUI menu suite; tray scraping nightly. ~2–3 days.
  6. CI — add CEF/WebView build jobs (cache CEF; CEF nightly if needed).
  7. Nightly — dialogs/modal/outward-facing; later Servo branch and iOS.

12. Open questions / spikes

  • Whether the CI libayatana version registers the item at /StatusNotifierItem vs /org/ayatana/NotificationItem/* — the sender-based capture handles both, but assertions on the path should not hard-code it.
  • laufey’s real macOS NSStatusItem uses a button image, not a title, so its AX bar item may have no AXTitle — 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 check passes against zbus 5.16 (§7.1).
  • macOS self-AX read of AXMenuBar / AXExtrasMenuBar and NSMenu.performActionForItem(at:): empirically confirmed on macOS 15.5 with no accessibility permission granted (§7.2).