13 KiB
02 — Wayland Protocol Layer (MIT-licensed)
This chapter covers how the Wayland protocol layer works in the MIT approach: protocol XML → generators → runtime, for both the browser side and the Python host side.
Layer 1 — Protocol XML (REUSED AS-IS)
Same 4 XML files as Greenfield, all upstream MIT or Greenfield's own MIT extension:
protocol/wayland.xml— upstream Wayland core (MIT)protocol/xdg-shell.xml— stable xdg-shell (MIT)protocol/wayland-drm.xml— legacy wl_drm (MIT, host-side only)protocol/web-bitmapbuf-unstable-v1.xml— Greenfield's custom extension for browser JS apps (MIT)
No changes needed. These files are the source of truth.
Layer 2 — Protocol Generators (REUSED AS-IS + pywayland scanner)
Browser side — REUSED from Greenfield (MIT)
@gfld/client-generator(MIT) — generates TS*Proxyclasses for browser apps that want to be Wayland clients of OUR compositor.@gfld/compositor-generator(MIT) — generates TS*Resource+*Requestsclasses for our custom browser compositor.@gfld/compositor-proxy-generator— NOT NEEDED (this generates interceptor files for the Node N-API addon; we don't have one).
Python side — pywayland's built-in scanner
pywayland ships its own scanner that parses the same wayland.xml +
wayland-protocols/*.xml and emits Python bindings
(pywayland/protocol/*.py). It uses the upstream wayland-scanner
C tool under the hood.
No new generator code to write. The scanner produces:
pywayland/protocol/wayland.py— wl_display, wl_registry, wl_compositor, wl_surface, wl_buffer, wl_seat, wl_keyboard, wl_pointer, wl_touch, wl_output, wl_shm, wl_data_device_manager, wl_subcompositorpywayland/protocol/xdg_shell.py— xdg_wm_base, xdg_surface, xdg_toplevel, xdg_popup, xdg_positioner- 60+ additional files for stable and unstable Wayland protocols
For Greenfield's custom web-bitmapbuf-unstable-v1.xml: not needed on
the Python side (native apps don't use it; it's browser-only). If you
need to expose it to native apps later, pywayland supports custom
protocol XMLs via pywayland.scanner.
Layer 3 — Wayland Runtime
Browser side — REUSED from Greenfield (MIT, verified clean)
@gfld/common (libs/common/src/Connection.ts) — the shared wire format
codec. Standard upstream Wayland wire format, byte-for-byte. 8-byte
header (u32 object_id | u16 opcode | u16 size), per-arg bytes.
@gfld/compositor-protocol (libs/compositor-protocol/src/) — the
server-side runtime. Provides Display, Client, Resource, Global,
Registry, plus generated *Resource classes for wayland, xdg_shell,
web_bitmapbuf.
CRITICAL VERIFICATION (from Agent M's audit): @gfld/compositor-protocol
imports ZERO code from @gfld/compositor (AGPL). It only depends on
@gfld/common (MIT). So it's safe to reuse in a proprietary codebase.
Python side — pywayland (Apache-2.0)
pywayland is the Python bindings to libwayland-server. License: Apache-2.0 (not MIT as the task brief assumed, but Apache-2.0 is permissive — allows proprietary use, modification, distribution).
API surface
Display — wraps struct wl_display*:
Display()— create.add_socket(name=None)→ returns"wayland-N"(becomes WAYLAND_DISPLAY).get_event_loop()→EventLoop.run()— block (we don't call this — we use asyncio integration instead).terminate()— break the run loop.init_shm()— register wl_shm global.add_shm_format(fmt)— add SHM format (YUV420, NV12, etc.).flush_clients()— send pending events.get_serial()→ next serial number.next_serial()— allocate a serial.destroy().add_destroy_listener(listener)
EventLoop — wraps struct wl_event_loop*:
.add_fd(fd, cb, mask, data)— register an FD for polling.add_signal(signum, cb, data).add_timer(cb, data)→Timer.add_idle(cb, data).dispatch(timeout_ms)— process pending events (we call this from asyncio).dispatch_idle().add_destroy_listener(listener)- MISSING:
.get_fd()— needed for asyncio integration, see §asyncio below
Client — wraps struct wl_client*:
Client(display, fd)— create from inbound socketpairClient(ptr=existing_ptr)— wrap existing.destroy().flush().get_credentials()→(pid, uid, gid)— used to identify which app spawned this client.add_destroy_listener(listener).get_object(id)→ResourceClient.from_resource(res)— static method- MISSING:
wl_display_add_client_created_listener— for new-client notifications. Available in upstream libwayland, exposed via 5-line cffi shim.
Resource (base class for generated Wl* types):
Resource(client, version, id=0).destroy().get_id()→ int.add_destroy_listener(listener)._post_event(opcode, *args)— emit event to client._post_error(code, msg)— send protocol error.dispatcher[opcode_or_name] = func— per-resource dispatch table (KEY mechanism).dispatcher.destructor = func
Global (base class for generated Wl*.global_class):
WlFoo.global_class(display)— instantiate.bind_func = handler— callback when a client binds.destroy()
Listener + Signal — standard observer pattern:
Listener(callable)Signal.add(listener)Signal.emit(data)Listener.remove()
asyncio integration (verified working)
pywayland bundles vanilla upstream libwayland 1.26.0 (verified by objdump
of the bundled .so — only standard wl_* exports, NO Greenfield patch
additions). To integrate with asyncio:
import asyncio, cffi, os, pywayland
from pywayland.server import Display
# 5-line cffi shim for wl_event_loop_get_fd (pywayland doesn't expose it)
extras_ffi = cffi.FFI()
extras_ffi.cdef("""
int wl_event_loop_get_fd(void *loop);
void *wl_display_get_event_loop(void *display);
int wl_event_loop_dispatch(void *loop, int timeout);
void wl_display_flush_clients(void *display);
""")
_pywl_libs = os.path.join(
os.path.dirname(os.path.dirname(pywayland.__file__)),
'pywayland.libs'
)
_lib = extras_ffi.dlopen([
os.path.join(_pywl_libs, f) for f in os.listdir(_pywl_libs)
if 'libwayland-server' in f
][0])
display = Display()
display.add_socket() # returns "wayland-N"
display.init_shm()
_ptr_int = int(pywayland.ffi.cast('uintptr_t', display._ptr))
_display_voidp = extras_ffi.cast('void *', _ptr_int)
_loop_voidp = _lib.wl_display_get_event_loop(_display_voidp)
_wl_fd = _lib.wl_event_loop_get_fd(_loop_voidp)
async def main():
loop = asyncio.get_running_loop()
def on_ready():
_lib.wl_event_loop_dispatch(_loop_voidp, 0)
display.flush_clients()
loop.add_reader(_wl_fd, on_ready)
await asyncio.Future() # run forever
asyncio.run(main())
This pattern is used in production by qtile's Wayland backend
(libqtile/backend/wayland/core.py). The qtile code is GPL-2.0 so
don't copy — but the architectural pattern is well-documented and
reusable.
Does pywayland need a patched libwayland-server?
NO — this is one of pywayland's biggest advantages over Greenfield.
- pywayland bundles and links against vanilla upstream libwayland 1.26.0 (no patches needed for basic use).
- Greenfield patches upstream libwayland-server with 9+ additions (in
native/wayland/src/wayland-server/changes.diff, MIT-licensed) to add wire-message interception hooks:wl_client_set_wire_message_cb,wl_client_set_registry_created_cb,wl_client_set_sync_done_cb, etc.
Should we apply Greenfield's changes.diff to our libwayland-server?
- Optional, recommended for the fast-sync optimization (skip browser round-trip when no prior browser-bound request occurred).
- The patches are MIT-licensed, so applying them to a vendored libwayland-server is fine.
- Without the patches: per-resource dispatchers work fine but with slightly more overhead (every request goes through the full Python dispatch path).
- With the patches: wire-message interception hook lets us route requests
to
{native: true}(handle in proxy) or{browser: true}(forward to browser compositor) before they hit the per-resource dispatcher.
Comparison to Greenfield's wayland-server.ts + wayland-server-addon.node
| Greenfield function | pywayland equivalent | Status |
|---|---|---|
createDisplay, destroyDisplay, addSocketAuto |
Display(), Display.destroy(), Display.add_socket() |
✅ |
flush, getCredentials, initShm |
Display.flush_clients(), Client.get_credentials(), Display.init_shm() |
✅ |
getFd, dispatchRequests (libuv integration) |
NOT exposed — 5-line cffi shim | ✅ (shimmable) |
setWireMessageCallback, setWireMessageEndCallback |
NO EQUIVALENT in upstream — Greenfield patch | Use per-resource dispatchers + apply changes.diff if you want the hook |
setRegistryCreatedCallback, setSyncDoneCallback, setBufferCreatedCallback |
NO EQUIVALENT in upstream — Greenfield patch | Reimplement by intercepting corresponding requests via per-resource dispatchers |
setClientDestroyedCallback |
Client.add_destroy_listener(Listener(cb)) |
✅ (different style) |
destroyClient, sendEvents, createWlResource |
Client.destroy(), _post_event(), WlFoo.resource_class(client, version, id) |
✅ |
initDrm, setupXWayland |
NO EQUIVALENT — use vendored Greenfield C files (MIT) | ✅ (vendored) |
createMemoryMappedFile, makePipe |
pywayland.utils.AnonymousFile(size), os.pipe() |
✅ |
Wire format (REUSED, same as Greenfield)
Standard upstream Wayland, byte-for-byte. See
../greenfield-analysis/02-wayland-protocol.md for the full byte layout.
Object lifecycle (REUSED semantics, different API)
pywayland uses standard libwayland object lifecycle:
- Client-allocated IDs (1-indexed, recycled via
wl_display.delete_id) - Server-allocated IDs (from
0xff000000) Resource.add_destroy_listener(Listener(cb))for cleanupClient.add_destroy_listener(Listener(cb))for client disconnect
File descriptor handling (REUSED approach)
Same ProxyFD JSON descriptor as Greenfield:
ProxyFD = { handle: number, type: 'pipe-read'|'pipe-write'|'shm'|'unknown', host: string }
In Python:
os.pipe()for pipesos.memfd_create()for shared memory (replaces Greenfield'screateMemoryMappedFilenative addon)- Subprocess + WebSocket endpoints for the FD-over-WebSocket workaround (same 9 endpoints)
Why this works
The protocol layer is fundamentally just:
- XML describing the protocol surface (upstream MIT)
- Code generators that produce typed bindings (MIT CLIs from Greenfield + pywayland's scanner)
- A runtime that marshalls/unmarshals wire bytes (MIT @gfld/common + Apache-2.0 pywayland)
None of these layers require AGPL code. The AGPL parts of Greenfield
(@gfld/compositor, @gfld/compositor-proxy) are the consumers of
these protocol layers — they implement the actual Wayland semantics
(Surface state, Seat, Keyboard, etc.). We replace those consumers with
custom clean-room implementations.
File reference
Reused from Greenfield (MIT)
| Path | Role |
|---|---|
protocol/wayland.xml |
Upstream Wayland core protocol |
protocol/xdg-shell.xml |
Stable xdg-shell protocol |
protocol/wayland-drm.xml |
Legacy wl_drm for DMA-BUF |
protocol/web-bitmapbuf-unstable-v1.xml |
Greenfield's custom extension for browser JS apps |
libs/common/src/Connection.ts |
Wire format codec (REUSED) |
libs/common/src/index.ts |
Public exports |
libs/compositor-protocol/src/westfield-runtime-server.ts |
Server-side runtime (REUSED) |
libs/compositor-protocol/src/protocol/ |
Generated server bindings (REUSED) |
libs/client-protocol/src/westfield-runtime-client.ts |
Client-side runtime (REUSED) |
libs/client-protocol/src/protocol/ |
Generated client bindings (REUSED) |
libs/client-generator/{ProtocolParser,ProtocolArguments,index}.mjs |
Browser client bindings generator (REUSED) |
libs/compositor-generator/{ProtocolParser,ProtocolArguments,index}.mjs |
Browser server bindings generator (REUSED) |
greenfield/packages/compositor-proxy/native/wayland/src/wayland-server/changes.diff |
Patches to libwayland-server (MIT, optional but recommended) |
Python equivalents (Apache-2.0 / MIT)
| Component | Role |
|---|---|
| pywayland | Python bindings to libwayland-server |
pywayland.server.Display |
wl_display wrapper |
pywayland.server.EventLoop |
wl_event_loop wrapper |
pywayland.server.Client |
wl_client wrapper |
pywayland.server.Resource |
wl_resource base class |
pywayland.server.Global |
wl_global base class |
pywayland.server.Listener, Signal |
Observer pattern |
pywayland.protocol.* |
Auto-generated protocol bindings (60+ interfaces) |
pywayland.utils.AnonymousFile |
memfd_create wrapper (replaces Greenfield's createMemoryMappedFile) |