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 25) 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_*), close_window, navigate, set_title, size/position get+set, set_resizable/is_resizable, set_always_on_top/is_always_on_top, 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.

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.

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

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, 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)
  .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) and whether it is a non-activating panel that does not steal keyboard focus. 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.

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.

#![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,
    },
    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.

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/ and builds libcef_dll_wrapper. The CEF version is pinned at the top of the Makefile (CEF_VERSION). Re-download with make clean-cef-vendor.

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.