Files

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 *Proxy classes for browser apps that want to be Wayland clients of OUR compositor.
  • @gfld/compositor-generator (MIT) — generates TS *Resource + *Requests classes 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_subcompositor
  • pywayland/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 socketpair
  • Client(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) → Resource
  • Client.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 cleanup
  • Client.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 pipes
  • os.memfd_create() for shared memory (replaces Greenfield's createMemoryMappedFile native addon)
  • Subprocess + WebSocket endpoints for the FD-over-WebSocket workaround (same 9 endpoints)

Why this works

The protocol layer is fundamentally just:

  1. XML describing the protocol surface (upstream MIT)
  2. Code generators that produce typed bindings (MIT CLIs from Greenfield + pywayland's scanner)
  3. 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)