diff --git a/recipes/mojo-wayland/README.md b/recipes/mojo-wayland/README.md new file mode 100644 index 00000000..617b560f --- /dev/null +++ b/recipes/mojo-wayland/README.md @@ -0,0 +1,330 @@ +# mojo-wayland + + + + +Mojo bindings for the Wayland client protocol, generated from the official +protocol XML. + +## License + +MIT — see [LICENSE](LICENSE). + +The generated files under `wayland/gen/` are derived from the Wayland core +protocol and xdg-shell protocol XMLs, which carry their own permissive +copyright notices, reproduced in `wayland/c/generated/`. The generated +bindings inherit those notices. + +## Versioning + +This Project follows the compiler version its built against, not its own versioning. Any releases not tied to a compiler revision will be mark with v1, etc... + +# AI Usage Notes + +Ai tools are used to maintain documentation and notes, and will continue to be used for that purpose. However, EVERY single line of (non-script-generated) actual code is human reviewed and tested. That is the expectation for any contribution. + +## Layout + +| Path | Purpose | +| -------------------------- | ---------------------------------------------------------- | +| `scripts/wayland_bindgen.py` | Generator: protocol XML → Mojo modules (see below) | +| `wayland/core.mojo` | Hand-written runtime (external_call stubs, WLArgument, shim glue) — NOT regenerated | +| `wayland/gen/wayland.mojo` | Generated: requests, event opcodes, listen/next accessors | +| `wayland/gen/xdg_shell.mojo` | Generated: xdg-shell (wm_base, surface, toplevel, ...) | +| `wayland/c/shim.c` | Tiny C shim: event capture dispatcher + interface table | +| `wayland/c/generated/` | wayland-scanner private-code + header for xdg-shell (xdg interfaces are NOT in libwayland-client) | +| `tests/test_pack.mojo` | Headless packaged-library smoke test (no compositor) | +| `tests/test_ffi_probe.mojo` | Executable FFI probe: zero-arg external_call status (live) | +| `tests/test_ffi_probe2.mojo` | Executable FFI probe: control + OwnedDLHandle (live) | +| `tests/test_connect.mojo` | Live test: connect → disconnect | +| `tests/test_globals.mojo` | Live test: connect → registry → print all globals | +| `tests/test_window.mojo` | Live test: full xdg-shell window with a wl_shm gradient buffer | +| `tests/recipe.yaml` | conda recipe for the modular-community channel | +| `dist/` | Packed release artifacts (gitignored) | + +## How it works + +- **Requests** are lowered through `wl_proxy_marshal_array` / + `wl_proxy_marshal_array_constructor_versioned` (libwayland exports no + per-request symbols; `wl_surface_commit` & co. are header-inline only). + Everything resolves via `external_call` against `libwayland-client`, linked + at build time with `-lwayland-client`. +- **Interface records** (`wl_registry_interface` etc.) are data symbols — the + C shim exposes a static name → `wl_interface*` table + (`wayland_shim_interface("wl_registry")`). +- **Events** use one generic C dispatcher (`wl_proxy_add_dispatcher`) that + captures `(opcode, args)` per proxy into a FIFO owned by the shim. Mojo + polls with `{iface}_next_{event}(queue, out_args)` and frees copied string + args with `_shim_string_free`. +- `WLArgument` mirrors `union wl_argument` exactly (**8 bytes on x86_64**). +- **Argument arrays are indexed by wire-signature position**: `new_id` slots + stay zeroed (libwayland writes the new proxy id there); every other arg + lands at its position in the XML arg list. Dense-packing non-`new_id` args + causes the compositor to read garbage (e.g. `get_xdg_surface` signature + `no` — the surface must be in slot 1, slot 0 is the new_id). +- **Child proxies inherit the parent's version**: constructors resolve the + version via `wl_proxy_get_version(parent)` — never hardcode the XML version + (binding `xdg_wm_base` at v3 and creating an `xdg_surface` at v7 is a + protocol error). +- `wl_registry.bind` is special: the wire signature is `usun`, so the + generated `wl_registry_bind` takes BOTH the interface pointer (for the + constructed proxy) and the interface NAME string (marshalled as the `s` + wire arg) plus name+version. +- **XDG interfaces** come from `wayland-scanner private-code` compiled into + the shim DSO; they are not exported by libwayland-client. +- **Minimum libwayland version**: every C symbol the bindings call + (`wl_proxy_marshal_array_constructor_versioned`, `wl_proxy_add_dispatcher`, + `wl_proxy_get_version`, ...) has existed since wayland **1.10** (2016), so + the practical floor is old. The conda recipe pins `wayland >=1.23` only + because that is the oldest conda-forge version actually tested — see + `[SYNC:minlib]` in `wayland/core.mojo` before adding a new stub. +- **Targets**: linux-64 and linux-aarch64 (both little-endian 64-bit; + `sizeof(union wl_argument)` is 8 on each). Other Wayland platforms + (big-endian or 32-bit) are unsupported by the `WLArgument` byte-cell + emulation — see `[SYNC:abi]` in `wayland/core.mojo`. + +## Using the library + +Add the dependency to `pixi.toml` (after the package is published, or with a +path dep for local development): + +```toml +[dependencies] +mojo-wayland = { path = "path/to/mojo-wayland" } # or version once published +``` + +Because the bindings resolve shim symbols at load time, any binary using the +package must link the shim DSO alongside `libwayland-client` (path shown for +a conda/pixi env; use `-L/usr/lib` for the system libwayland): + +```bash +mojo build app.mojo -I . \ + -Xlinker -L.pixi/envs/default/lib -Xlinker -lwayland-client \ + -Xlinker -L.pixi/lib -Xlinker -lwayland_shim +``` + +### Connect and enumerate globals + +```mojo +from wayland.core import ( + WLPtr, WLArgument, MAX_EVENT_ARGS, _shim_string_free, + wl_display_connect, wl_display_disconnect, + wl_display_dispatch, wl_display_roundtrip, stack_allocation, +) +from wayland.gen.wayland import ( + wl_display_get_registry, wl_registry_listen, wl_registry_next_global, +) + +def main() raises: + var display = wl_display_connect(0) # 0 = default socket ($WAYLAND_DISPLAY) + if Int(display) == 0: + raise Error("failed to connect to compositor") + + var registry = wl_display_get_registry(display) + + # install the capture dispatcher; the shim writes a queue handle to buf[0] + var queue_buf = stack_allocation[1, WLPtr]() + if wl_registry_listen(registry, queue_buf) != 0: + raise Error("registry_listen failed") + var queue = queue_buf[unsafe_offset=0] + + var args = stack_allocation[MAX_EVENT_ARGS, WLArgument]() # MUST be 16 slots + while wl_display_dispatch(display) > 0: + while wl_registry_next_global(queue, args): + # args[0]=name (u), args[1]=interface (s, malloc'd copy), args[2]=version (i) + ... +``` + +### Bind a global and issue requests + +Binding is generic (the wire signature is `usun`): pass the interface record +resolved via `shim_interface`, the interface name as a `WLString`, and the +global's name + negotiated version: + +```mojo +from wayland.core import shim_interface +from wayland.gen.wayland import wl_registry_bind, wl_compositor_create_surface + +var compositor = wl_registry_bind( + registry, shim_interface("wl_compositor"), + str_to_wlstring("wl_compositor"), name, version, +) +var surface = wl_compositor_create_surface(compositor) +wl_surface_commit(surface) +``` + +### Events: poll-based capture + +Every interface with events gets `{iface}_listen(proxy, out_queue)` plus one +`{iface}_next_{event}(queue, out_args)` accessor per event. Poll after each +`wl_display_dispatch`; the accessors return `False` when no matching event is +pending. String (`s`) args are malloc'd copies — free them with +`_shim_string_free`: + +```mojo +var evargs = stack_allocation[MAX_EVENT_ARGS, WLArgument]() +while xdg_toplevel_next_configure(top_queue, evargs): + pass # reconfigure: keep current size +if xdg_toplevel_next_close(top_queue): + running = False # no args -> takes no out_args buffer +``` + +Decoding helpers for the raw `WLArgument` slots (byte-level, little-endian — +see `tests/test_window.mojo` for the full versions): + +```mojo +def arg_as_uint(a: WLArgument) -> UInt32: + var v = 0 + for i in range(4): + v = v | (Int(a.raw[i]) << (8 * i)) + return UInt32(v) +``` + +### Full walkthrough + +`tests/test_window.mojo` is a complete, working example (~340 lines) covering +the whole WSI path: registry → bind `wl_compositor`/`wl_shm`/`xdg_wm_base` → +surface chain → xdg configure/ack handshake → `memfd_create` + `mmap` + +`wl_shm` pool → ARGB8888 gradient → attach/damage/commit → event loop that +answers pings and exits on close. Read it top-to-bottom before writing your +own client; it is kept compilable and compositor-verified by CI. + +### Memory and lifetime rules + +- Opaque objects are raw `WLPtr` handles; there is **no** automatic lifetime + management. Destructors are explicit generated functions + (`wl_shm_pool_destroy(pool)` etc. — marshal + `wl_proxy_destroy`). +- Popped string args are owned by you: `_shim_string_free(ptr)` when done. +- Event arg buffers must be `MAX_EVENT_ARGS` (16) entries — the shim zeroes + the whole array; a shorter stack buffer overflows. +- `memfd`/`mmap`-backed `wl_shm` buffers are plain libc calls via + `external_call` (see the `_memfd_create`/`_mmap` bindings in + `tests/test_window.mojo`); the library does not wrap them. + +## Extending to other protocols + +1. Pass the extension XML to the generator: + `pixi run gen` (edit the task in `pixi.toml` to append more XMLs) — it + emits `wayland/gen/{protocol}.mojo`. +2. Re-export the module in `wayland/__init__.mojo` (regenerated + automatically by the generator's final write step). +3. Interfaces not exported by `libwayland-client` (like all of xdg-shell) + need `wayland-scanner private-code` objects compiled into the shim — see + the `scanner` and `shim` tasks — plus their `wl_*_interface` records + added to `SHIM_IFACE_ENTRIES` in `wayland/c/shim.c`. +4. Check the generated opcodes against the XML: opcode = position of the + request/event in the interface element (0-based). + +## System dependencies + +Dependencies are split into two pixi environments: + +- **dev** (default) — `pixi run ` — day-to-day work: gen, scanner, + shim, build, and the live tests. Provides Mojo + Python. +- **packaging** — `pixi run -e packaging ` — the conda-package + workflow: `pack`, `test-pack`, `sync-recipe`, `test-build`. Adds + `rattler-build` (conda-forge) on top of everything dev has. Install it + with `pixi install -e packaging` (the dev env is the default). + +The **host system** additionally needs (per task): + +| Dependency | Needed by | Tasks | Notes | +| ---------- | --------- | ----- | ----- | +| C compiler (`gcc`) | dev + packaging | `shim`, `pack` | compiles `wayland/c/shim.c`; the conda recipe uses conda's own compiler instead | +| `wayland-scanner` | dev | `scanner` | ships with the `wayland` package | +| protocol XMLs (`wayland.xml`, `xdg-shell.xml`) | dev | `gen` | Arch: `wayland` + `wayland-protocols`; Debian: `libwayland-dev` + `wayland-protocols` | +| `libwayland-client` (+ headers) | dev | all test builds, `shim` | Debian: `libwayland-dev`; linked with `-lwayland-client` | +| Wayland compositor on `$WAYLAND_DISPLAY` | dev | live tests only | `test-connect`, `test-globals`, `test-window`, `test_ffi_probe*` | +| `sha256sum`, `awk` | packaging | `pack` | coreutils + gawk (present on any Linux) | +| `rattler-build` | packaging | `test-build` | conda-forge dep in this env, or system package | + +Deployment (the published conda package) needs **none** of these on the +consumer host — `pixi install mojo-wayland` from a channel pulls +`libwayland-client` as a run-dependency automatically, and the recipe builds +in rattler-build's isolated env (conda gcc + conda wayland). The generated +outputs (`wayland/gen/`, `wayland/c/generated/`) are committed, so `gen` and +`scanner` are only required when protocols change. + +Example install on Arch: + +```bash +sudo pacman -S --needed wayland wayland-protocols +``` + +Debian/Ubuntu: + +```bash +sudo apt install libwayland-dev wayland-protocols +``` + +## Tasks + +```bash +pixi run gen # regen Mojo bindings from protocol XML +pixi run shim # build the C shim DSO into .pixi/lib/ +pixi run build # package the Mojo side → .pixi/envs/default/lib/wayland.mojoc +pixi run test-globals # build + run live registry test on $WAYLAND_DISPLAY +pixi run test-window # build + run live xdg-shell window (400x300 gradient) +``` + +(Packaging tasks live in a separate `packaging` environment — see +[System dependencies](#system-dependencies) and +[Packaging](#packaging-modular-community).) + +The registry test prints every global your compositor advertises and exits 0. +The window test opens a real 400x300 teal-purple gradient window (answers xdg +pings, exits on close), proving: connect, generic bind, xdg-shell configure +handshake, wl_shm via memfd, buffer attach/commit, and the event loop. + +Note: on some pixi versions `pixi run` sandboxes the task environment in a way +that breaks `wl_display_connect` (connection refused at runtime). If the test +fails to connect under `pixi run` but builds fine, invoke the binary directly: + +```bash +pixi run shim && pixi run build +pixi run mojo build tests/test_globals.mojo -I . -o .pixi/test_globals \ + -Xlinker -L.pixi/lib -Xlinker -L.pixi/envs/default/lib \ + -Xlinker -lwayland_shim -Xlinker -lwayland-client +LD_LIBRARY_PATH=.pixi/lib:.pixi/envs/default/lib .pixi/test_globals +``` + +## Packaging (modular-community) + +```bash +pixi run -e packaging pack # shim DSO + wayland.mojoc + sha256sum → dist/ +pixi run -e packaging test-pack # headless smoke test of dist/ artifacts (no compositor) +pixi run -e packaging sync-recipe # copy recipe + test into ../modular-community/recipes/ +pixi run -e packaging test-build # full rattler-build build + conda test of tests/recipe.yaml +``` + +### Archival +The precompiled `.mojoc` we release against stable mojo-compiler updates for archival purposes. +This can also be used to pin against a specific version, just comment out the version check in pack.sh. + +`pack` produces `dist/wayland.mojoc`, a precompiled artifact of the library. + +- **The mojoc is compiler-version-locked, exactly.** The consuming compiler + must be the *same build* it was compiled with. `pack` writes + `dist/mojo.version` recording the producing compiler; check it before + consuming. +- **The source tree is the universal fallback**: `-I ` + works on any compiler that can parse it (the version error above + disappears when importing from source) — that's how this repo's own + tests consume the library. +- The shim DSO is a normal C shared object: no version lock, just + `-Xlinker`/`-L`/`-lwayland_shim` at link time and `libwayland-client` + at runtime (see [Using the library](#using-the-library)). + +prefer the conda package (pixi/modular) when available + + +## Known limitations + +- Events are poll-based (`dispatch` then `next_*` per event), not callback- + based; a callback API would need C→Mojo trampolines. +- String args in popped events must be freed with `_shim_string_free`. +- The core protocol + xdg-shell are generated; further extensions need their + XMLs passed to the generator (and any non-libwayland interfaces added to + the shim's `SHIM_IFACE_ENTRIES` + scanner private-code build). +- Destructor requests marshal then `wl_proxy_destroy`; there is no + automatic object-lifetime management. diff --git a/recipes/mojo-wayland/mojo-wayland.jpeg b/recipes/mojo-wayland/mojo-wayland.jpeg new file mode 100644 index 00000000..d1d401f8 Binary files /dev/null and b/recipes/mojo-wayland/mojo-wayland.jpeg differ diff --git a/recipes/mojo-wayland/recipe.yaml b/recipes/mojo-wayland/recipe.yaml new file mode 100644 index 00000000..079e9ed6 --- /dev/null +++ b/recipes/mojo-wayland/recipe.yaml @@ -0,0 +1,77 @@ +context: + version: "1.1.0" + +package: + name: "mojo-wayland" + version: ${{ version }} + +source: + - git: https://github.com/Phelsong/mojo-wayland.git + rev: 98cb6fc1958b8d074667c0ac60856c2f1f7fe9ba + +build: + number: 0 + skip: + # linux-64 + linux-aarch64 only (wayland is linux-only; the Mojo runtime + # is little-endian 64-bit — see [SYNC:abi] in wayland/core.mojo) + - not (linux and (x86_64 or aarch64)) + script: + interpreter: bash + content: + - | + set -euo pipefail + # Build the C shim DSO (interface table + event capture). The + # xdg-shell private code is committed under wayland/c/generated, + # so wayland-scanner is not needed at build time. Headers live in + # the host env ($PREFIX), the compiler in the build env. + $CC $CFLAGS -I"$PREFIX/include" -shared -fPIC -O2 -Wall \ + -o "${PREFIX}/lib/libwayland_shim.so" \ + wayland/c/shim.c wayland/c/generated/xdg-shell-protocol.c \ + -L"$PREFIX/lib" -lwayland-client + mkdir -p "${PREFIX}/lib/mojo" + mojo precompile wayland -o "${PREFIX}/lib/mojo/wayland.mojoc" + +requirements: + build: + - ${{ compiler('c') }} + - mojo-compiler ==1.1.0 + # wayland floor: every C symbol the shim/bindings use + # (wl_proxy_marshal_array_constructor_versioned, wl_proxy_add_dispatcher, + # wl_proxy_get_version) exists since 1.10; 1.23 is the oldest TESTED + # conda-forge wayland, not an ABI requirement. See [SYNC:minlib] in + # wayland/core.mojo. + - wayland >=1.23,<1.27 + host: + - mojo-compiler ==1.1.0 + - wayland >=1.23,<1.27 + run: + - mojo-compiler ==1.1.0 + - wayland >=1.23,<1.27 + +tests: + - script: + - if: unix + then: + # The Mojo bindings resolve shim symbols at load time via + # external_call, so the shim DSO is linked into the test binary. + # This test is headless: no Wayland compositor required. + - mojo run -Xlinker $PREFIX/lib/libwayland_shim.so -I $PREFIX/lib/mojo test_pack.mojo + files: + recipe: + - test_pack.mojo + requirements: + run: + - mojo-compiler ==1.1.0 + +about: + homepage: https://github.com/Phelsong/mojo-wayland + license: MIT + license_file: LICENSE + summary: Mojo bindings for libwayland-client, generated from protocol XML + repository: https://github.com/Phelsong/mojo-wayland + +extra: + maintainers: + - phelsong + project_name: + - mojo-wayland \ No newline at end of file diff --git a/recipes/mojo-wayland/test_pack.mojo b/recipes/mojo-wayland/test_pack.mojo new file mode 100644 index 00000000..9a05a86c --- /dev/null +++ b/recipes/mojo-wayland/test_pack.mojo @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Josh S Wilkinson +# Headless smoke test for the packaged `wayland` library (no compositor needed). +# +# Verifies the C shim resolves protocol interface records by name, exercising +# both libwayland_shim.so and the scanner-generated xdg-shell private code +# compiled into it. Run with the shim preloaded: +# +# LD_PRELOAD=$PREFIX/lib/libwayland_shim.so \ +# mojo run -I $PREFIX/lib/mojo/wayland.mojopkg test_pack.mojo +from wayland.core import shim_interface + + +def main() raises: + var iface = shim_interface("wl_registry") + if Int(iface) == 0: + raise Error("shim_interface(wl_registry) returned NULL") + + # xdg-shell interfaces are NOT exported by libwayland-client; they come + # from wayland-scanner private code compiled into the shim DSO itself. + var xdg = shim_interface("xdg_wm_base") + if Int(xdg) == 0: + raise Error("shim_interface(xdg_wm_base) returned NULL") + + print("✅ wayland package smoke test PASSED")