Introduction
laufey is a web embedded framework: build cross-platform desktop apps with web technologies and your choice of browser engine.
It is built around a small C ABI that separates the browser engine (the backend) from your application logic (the runtime). You write the runtime in Rust against one portable API; laufey ships prebuilt backends — Chromium via CEF, the system WebView, and an engine-free Winit windowing backend — and your app runs on any of them.
use laufey::{Value, Window};
fn main() {
Window::new(800, 600)
.title("My App")
.bind("greet", |call| {
let name = call
.args
.first()
.and_then(|v| v.as_string())
.unwrap_or("World");
call.resolve(Value::String(format!("Hello, {name}!")));
})
.load("index.html");
}
laufey::main!(main);
Where to go next
- Architecture — how backends and runtimes fit together.
- C ABI — the
laufey.hcontract: entry points, the API table, and the value model. Read this if you’re implementing a backend or a binding. - Backends — CEF, WebView, and Winit, and how they differ.
- The feature pages — windows, JavaScript interop, menus, dialogs, tray, notifications, and more — each with a usage example and its per-platform notes.
- Packaging & distribution — bundling, signing, and updates.
- Building — prerequisites and
maketargets.
The source lives at github.com/littledivy/laufey.
Architecture
Overview
laufey separates browser engines (backends) from application logic
(runtimes). Backends are native executables; runtimes are shared libraries
(.dylib/.so/.dll) loaded at startup. They communicate through a C ABI
defined in capi/include/laufey.h.
┌──────────────────┐ ┌───────────────────┐
│ Backend (exe) │ ──C ABI──▶ Runtime (dylib) │
│ CEF / WebView / ◀─────────│ User app logic │
│ Winit │ │ (links laufey capi) │
└──────────────────┘ └───────────────────┘
Backends
| Directory | Engine | Language | Window ownership |
|---|---|---|---|
cef/ | Chromium Embedded Framework | C++ | CEF Views (internal) |
webview/ | System webview (WKWebView / WebView2 / WebKitGTK) | C++ (per-platform) | Created directly |
winit/ | None (winit only, no web content) | Rust (winit) | Created directly |
An experimental Servo backend lives on the
servo branch.
Runtime (capi)
capi/ provides the Rust crate that runtimes link against. It wraps the raw C
function pointers from laufey_backend_api_t into safe Rust types (Window,
Value, JsCall, KeyboardEvent, MouseClickEvent, etc.).
Key Patterns
The C ABI contract (laufey_backend_api_t)
The central interface is a struct of function pointers
(capi/include/laufey.h). The backend fills this struct and passes a pointer to
laufey_runtime_init(). The runtime stores it for the process lifetime. Every
capability (navigation, JS execution, event handlers, window management) is a
nullable function pointer in this struct.
Adding a new API: add the field to laufey_backend_api_t in
capi/include/laufey.h, then implement it in every backend. Both C++ backends
include the canonical header from capi/include/ via the LAUFEY_INCLUDE_DIR
CMake variable — there is no second copy to sync.
Callback registration (event handlers)
All event handlers follow the same pattern:
- Define a C callback type (
laufey_keyboard_event_fn,laufey_mouse_click_fn) - Add a
set_*_handler(backend_data, callback, user_data)function pointer to the API struct - Backend stores the callback+user_data behind a mutex
- Backend dispatches from its native event handler, passing the user_data back
On the runtime side (capi/src/lib.rs), an unsafe extern "C" trampoline
converts C types to Rust types and forwards to a stored Box<dyn Fn(Event)>.
Events are non-consuming – handlers always return the event to the underlying engine. This is an interception model, not a consumption model.
C++ backend code sharing (backend-common)
The CEF and webview backends both link backend-common/, a CMake static library
that holds platform implementations of APIs the two backends would otherwise
duplicate. Each backend add_subdirectorys it and links laufey_backend_common
from its platform branch.
The bridge is intentionally minimal — common code never touches the
backend-specific laufey_value_t types. Each backend pre-parses
laufey_value_t into plain C++ structs (laufey_common::NotificationOptions,
etc.) before calling into common functions. Header:
backend-common/include/laufey_backend_common.h.
Currently shared:
| Area | macOS | Windows | Linux |
|---|---|---|---|
| Notifications | notifications_mac.mm (UN) | notifications_win.cc (NIIF) | notifications_linux.cc (notify-send) |
| Dialogs | dialog_mac.mm (NSAlert) | dialog_win.cc (MessageBoxW + PowerShell prompt) | dialog_linux.cc (gtk_message_dialog) |
| Permissions | permissions_mac.mm (UN auth) | permissions_stub.cc (always granted) | permissions_stub.cc (always granted) |
| Dock | dock_mac.mm (badge / bounce / visible / dock menu storage / reopen handler) | per-backend (FlashWindowEx) + title_badge.cc for the badge | per-backend (gtk_window_set_urgency_hint) + title_badge.cc for the badge |
| Key mapping | keymap_mac.mm (NSEvent → W3C) | keymap_vk.cc (VK → W3C; CEF uses on every platform) | keymap_gdk.cc (GDK → W3C) |
| App / context menu | menu_mac.mm (NSMenu) | capi/include/win32_menu.h (HMENU + SetMenu / TrackPopupMenu) | menu_linux.cc (GtkMenu / GtkMenuBar) |
| Tray icons | tray_mac.mm (NSStatusItem) | tray_win.cc (Shell_NotifyIcon + WIC + HMENU) | tray_linux.cc (libappindicator + g_idle_add) |
| Option parsing | parse_options.cc (compiled on every platform; bridges laufey_value_t → plain structs) | ||
| Title-prefix badge bookkeeping | title_badge.cc (ApplyTitlePrefixBadge — used by CEF Win+Linux and webview Win+Linux for Dock-badge fallback) |
Notes:
win32_menu.his the older shared header-only library for Windows menu construction; it predatesbackend-commonand stays as-is. The two patterns coexist — both backends usewin32_menufor Windows app/context menus, andbackend-commonfor the rest.- The dock fallback on Windows/Linux still iterates per-window state inside each
backend (because the native title-get/set APIs differ —
SetWindowTextW(HWND)vsgtk_window_set_title(GtkWindow*)vs CEF’sCefWindow::SetTitle), but the saved-titles bookkeeping and"(badge) " + titlestring concatenation are unified inlaufey_common::ApplyTitlePrefixBadge. - FlashWindow (Windows) and
gtk_window_set_urgency_hint(Linux) forbounce_dockremain per-backend — each is ~5 LOC and the native call differs enough that an abstraction would cost more than it saves.
To add a new shared API: declare it in laufey_backend_common.h, add the
implementation file(s) to backend-common/CMakeLists.txt, then call it from
each backend’s existing API trampoline.
Winit backend code sharing (backend-winit-common)
The winit/ backend uses winit for windowing; the Servo backend on the servo
branch shares this same code. Shared code lives in
backend-winit-common/src/lib.rs:
BackendAccesstrait: each backend implements this to provide access to itsCommonState, event loop proxy, and event type mapping.define_common_backend_fns!macro: generates theunsafe extern "C"functions for all common operations (title, size, position, visibility, event handlers, etc.).fill_common_api!macro: wires those generated functions into aLaufeyBackendApistruct.CommonState: holds pending window mutations (Mutex<Option<T>>) and event handler callbacks.handle_common_event(): processesCommonEventvariants against a winitWindow.
To add a new winit-based API: add the pending state to CommonState, the
function to define_common_backend_fns!, the assignment to fill_common_api!,
and the dispatch to handle_common_event(). The winit backend picks it up
automatically (and the Servo branch, if rebased).
Pending state pattern (async window ops)
Backend API functions are called from the runtime thread, but window operations must happen on the UI thread. The pattern is:
- Store the desired value in a
Mutex<Option<T>>onCommonState - Send an event via the winit
EventLoopProxy - On the UI thread, take the pending value and apply it to the window
C++ backends use platform-specific dispatch instead (dispatch_async on macOS,
PostMessage on Windows, g_idle_add on Linux).
CEF: no native mouse/input handlers
CEF provides CefKeyboardHandler for keyboard events but has no equivalent
CefMouseHandler. This is because CEF Views creates and owns the native
window internally – the embedder has no direct access to the native event loop.
Workaround: platform-specific native event monitors that hook into the OS event system:
| Platform | Technique | File |
|---|---|---|
| macOS | [NSEvent addLocalMonitorForEventsMatchingMask:] | cef/src/main_mac.mm |
| Windows | WM_*BUTTON* messages in WindowProc (via CefWindow::GetWindowHandle() + subclassing) | cef/src/main_win.cc (TODO) |
| Linux | GTK button-press-event / button-release-event signals (via CefWindow::GetWindowHandle()) | cef/src/main_linux.cc (TODO) |
The monitor functions (InstallNativeMouseMonitor() /
RemoveNativeMouseMonitor()) are declared in cef/src/runtime_loader.h and
called from LaufeyWindowDelegate::OnWindowCreated / OnWindowDestroyed in
cef/src/app.cc. This is the same approach Electron uses – Electron creates
native windows directly (bypassing CEF Views), but since we use CEF Views, we
instead install post-hoc monitors on the window CEF creates.
Webview backends: direct native window access
Unlike CEF, the webview backends create their own native windows, so event interception is straightforward:
| Platform | Keyboard | Mouse |
|---|---|---|
macOS (webview_macos.mm) | NSEvent addLocalMonitorForEventsMatchingMask: for key events | Same mechanism for mouse events |
Windows (webview_windows.cc) | WM_KEYDOWN / WM_KEYUP in WindowProc | WM_*BUTTON* in WindowProc |
Linux (webview_linux.cc) | GTK key-press-event / key-release-event signals | GTK button-press-event / button-release-event signals |
W3C UI Events key mapping
Keyboard events expose key (logical, e.g. "a", "Enter") and code
(physical, e.g. "KeyA", "Enter") following the W3C UI Events specification.
Each platform has its own mapping:
- winit backends:
winit_key_to_string()/winit_code_to_string()inbackend-winit-common - CEF:
CefKeyCodeToString()/CefKeyCodeToCode()incef/src/app.cc(maps Windows virtual key codes) - webview macOS:
NSEventKeyToString()/NSEventKeyCodeToCode()(maps macOS key codes) - webview Windows:
VirtualKeyToKey()/VirtualKeyToCode()(maps Win32 VK codes) - webview Linux:
GdkKeyvalToKey()/GdkKeycodeToCode()(maps GDK keyvals and evdev hardware keycodes)
Mouse button mapping
Mouse buttons are normalized to LAUFEY_MOUSE_BUTTON_* constants.
Platform-specific mappings:
- NSEvent
buttonNumber: 0=left, 1=right, 2+=other (detect via event type mask) - Win32: separate
WM_*BUTTON*messages per button;XBUTTON1/XBUTTON2for back/forward - GDK:
event->button: 1=left, 2=middle, 3=right, 8=back, 9=forward
Value marshalling
The laufey API has a rich value type (laufey_value_t) for JS interop. Backends
own the value representation:
- CEF: wraps
CefValue/CefListValuedirectly - Webview: uses a custom
Valueclass with JSON serialization for JS communication - Winit backends: stub implementations (no JS engine)
The runtime crate (capi/src/lib.rs) wraps these into a Rust Value enum via
the function pointer API, completely opaque to the value’s backend
representation.
Modifier flags
All platforms normalize keyboard modifiers to a shared bitmask:
LAUFEY_MOD_SHIFT = 1 << 0
LAUFEY_MOD_CONTROL = 1 << 1
LAUFEY_MOD_ALT = 1 << 2
LAUFEY_MOD_META = 1 << 3
Each platform maps from its native representation (NSEventModifierFlags,
GetKeyState(), GdkModifierType, CefEventFlags, winit ModifiersState).
C ABI
laufey is built around a single C header,
capi/include/laufey.h.
It defines the boundary between a backend (a native executable embedding a
browser engine) and a runtime (a shared library holding the application
logic). The backend implements the ABI; the runtime consumes it.
LAUFEY_API_VERSION (currently 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) | Signature | Role |
|---|---|---|
laufey_runtime_init | int(const laufey_backend_api_t* api) | Backend hands the runtime the API table. Stash it; return 0 on success. |
laufey_runtime_start | int(void) | Run application setup (create windows, register handlers). Returns when ready. |
laufey_runtime_shutdown | void(void) | Tear down before the process exits. |
The backend dlopens the runtime, resolves these symbols, calls init then
start, and drives the OS event loop. Control flows backend → runtime through
the API table, and runtime → backend through the registered callbacks.
The API table
laufey_backend_api_t is a struct of function pointers plus two data fields:
struct laufey_backend_api {
uint32_t version; // == LAUFEY_API_VERSION the backend was built against
void* backend_data; // opaque; pass back as the first arg of every call
/* ... function pointers ... */
};
Every function takes backend_data as its first argument, so the table is a
hand-rolled vtable with no global state. Windows are referenced by an opaque
uint32_t window_id returned from create_window.
The pointers group into:
- Window lifecycle —
create_window,create_window_ex(style flags, seeLAUFEY_WINDOW_FLAG_*),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 interop —
set_js_call_handler,js_call_respond,invoke_js_callback,release_js_callback,execute_js,set_js_namespace,poll_js_calls,set_js_call_notify. - Event handlers —
set_keyboard_event_handler,set_mouse_click_handler,set_mouse_move_handler,set_wheel_handler,set_cursor_enter_leave_handler,set_focused_handler,set_resize_handler,set_move_handler,set_close_requested_handler. - Window handles —
get_window_handle,get_display_handle,get_window_handle_type(for GPU surface creation). - Menus —
set_application_menu,show_context_menu,open_devtools. - Dialogs —
show_dialog,string_free. - Dock / taskbar —
set_dock_badge,bounce_dock,set_dock_menu,set_dock_visible,set_dock_reopen_handler. - Tray —
create_tray_icon,destroy_tray_icon,set_tray_icon(_dark),set_tray_tooltip,set_tray_menu, click handlers,get_tray_icon_bounds. - Notifications —
show_notification,close_notification. - Permissions —
query_permission,request_permission.
See the feature pages for behavior and per-platform differences.
Values (laufey_value_t)
laufey_value_t is an opaque, dynamically-typed value used for everything
crossing the JS ↔ native boundary (call arguments, results, menu templates,
notification options). It models the JSON types plus binary blobs and
JS-callback handles:
- Inspect:
value_is_null/_bool/_int/_double/_string/_list/_dict/_binary/_callback. - Read:
value_get_bool/_int/_double;value_get_string(returns a heap buffer freed withvalue_free_string); list access (value_list_size,value_list_get); dict access (value_dict_get,value_dict_has,value_dict_size,value_dict_keys+value_free_keys);value_get_binary;value_get_callback_id. - Build:
value_null/_bool/_int/_double/_string/_list/_dict/_binary(constructors takebackend_data), thenvalue_list_append/_set,value_dict_set. - Free:
value_free.
Ownership. Constructors return a value the caller owns and must value_free
(unless handed off). Functions that accept a template — set_application_menu,
show_context_menu, set_tray_menu, set_dock_menu, show_notification —
take ownership of the passed value and free it themselves.
A _callback value wraps a JS function passed as an argument: read its
value_get_callback_id, then call it later with invoke_js_callback(id, args)
and free it with release_js_callback(id).
JavaScript call flow
- The runtime exposes a namespace in the page (
set_js_namespace, default"Laufey") and registersset_js_call_handler. - Page JS calls
Laufey.someMethod(args…); the backend invokes the handler with acall_id, the method name, and the arguments as alaufey_value_tlist. - The runtime does its work and replies with
js_call_respond(call_id, result, error)— resolving or rejecting the JS-side promise.
execute_js runs a script in a window and delivers its result/error through a
laufey_js_result_fn. When the runtime services calls off the UI thread, the
backend signals readiness via set_js_call_notify and the runtime drains the
queue with poll_js_calls.
Threading
All API calls must happen on the UI thread the backend’s event loop runs on.
post_ui_task hops onto it from another thread. show_dialog blocks on the UI
thread but pumps OS events so other windows stay responsive.
Backends
A backend is the native executable that hosts a browser (or windowing) engine
and implements the C ABI. laufey ships three; a fourth is on a
branch. All implement the same laufey_backend_api_t, so a runtime is portable
across them — the differences are in engine, process model, size, and a few
features that a given engine can’t express on a given OS (see
the feature pages).
| Backend | Engine | Process model | Bundled | JS bridge |
|---|---|---|---|---|
| CEF | Chromium 144 | multi-process | yes | yes |
| WebView | system native | single | no | yes |
| Winit | none | single | n/a | no |
Platform support is x86_64 + aarch64 on macOS and Linux, x86_64 on Windows. 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)
cmakeandninja- macOS:
brew install llvm(forlibclang) - Linux: GTK + X dev packages
(
libgtk-3-dev libxkbcommon-dev libxrandr-dev libxrender-dev libxtst-dev) - Windows: Visual Studio (MSVC) + LLVM; build from a
vcvars64shell
make check-deps verifies the base tools.
Backends
make cef # CEF backend (downloads + builds the CEF dll wrapper first)
make webview # system WebView backend (WebKitGTK / WebView2)
make winit # windowing-only backend, no web engine
make all # everything
make cef runs make cef-deps, which downloads the pinned CEF build into
vendor/cef/ 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.