24 KiB
07 — Native App Bridge (How Real Linux Apps Get Connected)
This chapter covers Greenfield's native app bridge: how real Linux applications get launched, connected to the proxy, and routed to the browser. This is the bridge that makes a Linux Wayland app appear inside the browser. This is the most important chapter for the user — they want to run real Linux apps in their Odoo 20 web app.
Two app models — don't conflate them
Greenfield has two app models:
Model 1: WASM web apps (browser-resident)
Built with the Greenfield SDK (Emscripten), run inside the browser via
MessageChannel. The sdk/ directory serves only this model. Apps are
compiled to WASM, run in the same browser tab as the compositor, and
speak Wayland over a MessagePort instead of a UNIX socket.
Model 2: Native Linux apps (the focus of this chapter)
Real ELF binaries (gtk4-demo, kwrite, xterm) running as child
processes of the Node.js proxy. Apps use the host's own system libraries
— no sysroot, no chroot, no container.
Process model — three layers
Layer 1: CLI Front Door (compositor-proxy-cli/src/main.ts)
A single Node.js process listens on :8081. Per browser session, it
fork()s one SessionProcess child — one child per compositorSessionId,
not per app. Multiple apps in the same browser tab share one child.
WebSocket upgrades are handed off to the matching child via
childProcess.send(wsUpgrade, socket).
CLI args:
--bind-port=8081(default)--applications=<json>(URL → executable map)--encoder=x264|nvh264|vaapih264--render-device=/dev/dri/renderD128--base-url,--allow-origin,--bind-ip,--basic-auth
Layer 2: Session Process (SessionProcess.ts)
The forked child. Receives IPC messages:
start— createsSession+SessionControllerlaunchApp— spawns the actual app binarywsUpgrade— hands WS to the controller
On launchApp it calls launchApplication() from @gfld/compositor-proxy,
which spawn()s the executable, captures pid, replies to main with
{pid, key}. On session.close() the process exits.
Layer 3: Proxy Core (compositor-proxy/src/)
NativeWaylandCompositorSession— creates a realwl_displayviacreateDisplay(), callsaddSocketAuto()which creates the UNIX socket at$XDG_RUNTIME_DIR/wayland-Nand returns the namewayland-N(this becomesWAYLAND_DISPLAY). CallsinitShm()(adds YUV420/NV12/YUV444/ NV21 formats) andinitDrm()(opens/dev/dri/renderD128via EGL/GBM, registerszwp_linux_dmabuf_v1+wl_drmglobals). Integrates the wl_display FD into Node's libuv loop viastartPoll().NativeWaylandClientSession— per-client wire-message interceptor. UsesMessageInterceptor+ auto-generated per-resource interceptor modules (src/protocol/wl_*_interceptor.js). Each request returns{native, browser, neverReplies?}routing decision. Handleswl_display.syncfast-path (skips browser round-trip when no reply expected). Marshalls FDs asProxyFDJSON.NativeAppContext— per-app state:pid,key(16-hex random signaling auth), signaling WS, client sessions, data channels. Lifecycle: 10-min SIGHUP grace on browser disconnect, SIGTERM→SIGKILL escalation (10 s) on force-quit, auto-destroy on app exit.SessionController— WS demultiplexer by pathname:/signal(per-app control),/channel(multiplexed data channels),/mkfifo,/mkstemp-mmap,/read-fd,/write-fd-as-stream,/request-keyframe.XWaylandSession— lazy Xwayland fork (only on first X client connect). Xwayland itself becomes a Wayland client of the proxy. Thewm_fdis bridged via xtsb to the browser, which implements the actual XWM logic.Channel—ARQChannel(KCP-over-WebSocket, reliable, low-latency) for PROTOCOL/FRAME/XWM;SimpleChannelfor FEEDBACK.
App launch flow (definitive answer)
- Browser sends
GET http://proxy:8081/<path>withX-Compositor-Session-Id: <uuid>header. - Main process forks a
SessionProcessif none exists for that session ID, sendsstartIPC. - Main sends
launchAppIPC with{name, executable, args, env, serial}. SessionProcesscallslaunchApplication():appEnv = {...process.env, ...env, WAYLAND_DISPLAY: session.nativeWaylandCompositorSession.waylandDisplay}child_process.spawn(executable, args, {env: appEnv})— plainspawn, no chroot/container
- On
spawnevent, capturespid, createsNativeAppContext, registersexitlistener. - Replies to main with
{pid, key}→ main returns HTTP 201 with{baseURL, signalURL, key, pid, name}. - Browser opens
signalURLas WebSocket → control channel established. - App connects to
$XDG_RUNTIME_DIR/wayland-N→ libwayland fireson_client_created→ proxy looks up PID viagetCredentials(), walks/proc/<pid>/statusPPid chain to match aNativeAppContext. - For each new
wl_client, creates aPROTOCOLARQ channel and sendsCONNECT_CHANNELover the signaling WS; browser opens a new WS at/channel?id=…. - Wayland wire messages now flow: app → libwayland →
setWireMessageCallback→ JS interceptor → routed to{native, browser}.
Native addon architecture
Important: The "native" code is C, not C++ (one GLSL shader string + GObject-style). Uses Node-API (N-API).
wayland-server-addon.node (1231 lines C)
Wraps a patched libwayland-server (libwayland-server.so.0 with
Greenfield additions in native/wayland/changes.diff):
wl_client_set_wire_message_cbwl_client_set_registry_created_cbwl_client_set_sync_done_cbwl_client_add_resource_created_listenerwl_display_set_global_created_cbwl_resource_destroy_silentlywl_get_server_object_ids_batchwl_connection_copy_fds_in
Plus the westfield shared lib (EGL/GBM init, wlr_linux_dmabuf_v1,
wlr_drm, westfield-xwayland). 31 N-API functions exposed. This is
what makes the proxy a real Wayland compositor.
proxy-poll-addon.node (127 lines C)
Tiny wrapper around uv_poll_t (libuv, not raw epoll — though libuv uses
epoll under the hood). startPoll(fd, cb) integrates the wl_display
event-loop FD into Node's libuv loop so wayland events fire as Node
callbacks without blocking.
proxy-encoding-addon.node (290 lines C) + libproxy-encoding.so (~1400 lines C)
GStreamer H.264/PNG encoder. Runs on a dedicated GMainLoop thread with
an eventfd-based message queue (SyncSource GSource). Two pipelines per
surface (opaque + alpha) for translucent windows. Supports x264
(software), nvh264 (Nvidia NVENC), vaapih264 (Intel, experimental).
Zero-copy DMA-BUF path via EGLImage → GStreamer GL. Uses
napi_threadsafe_function to marshal encoded samples back to Node.
XWayland bridge
Native side (westfield-xwayland.c, 466 lines C)
- Creates
/tmp/.X11-unix/X<N>listener socket setenv("DISPLAY", ":<N>", 1)- Waits for X client connect
- On connect:
socketpair(AF_UNIX)× 3 (wayland / wm / display) wl_client_create(wl_display, sv[0])— Xwayland itself becomes a Wayland client of the proxy!fork()+execlp("Xwayland", "Xwayland", ":<N>", "-ac", "-rootless", "-listen", "<unix_fd>", "-displayfd", "<display_fd>", "-wm", "<wm_fd>", NULL)
xtsb (X11 TypeScript Bindings)
libs/xtsb/: pure-TS X11 client, no libxcb. Dual-environment (Node
net.Socket + browser WebSocket). Auto-generated from XCB XML protocol
files via xcbgen/ts_client.py. Handles MIT-MAGIC-COOKIE-1 auth from
~/.Xauthority. The browser-side xtsb implements the actual XWM logic
(translates X CreateNotify events → xdg_toplevel Wayland surfaces).
XWaylandSession.ts uses nodeFDConnectionSetup(wmFd) to wrap the wm_fd
as a net.Socket, performs X11 auth, and pipes the X11 wire protocol
through the XWM data channel to the browser.
SDK misconception corrected
The sdk/ directory is NOT for native apps. It's exclusively for
WASM apps that run inside the browser via Emscripten. The
sysrootlibs/ cross-compiles wayland/gtk/cairo/etc. to wasm32. Native
Linux apps on the proxy use the host's own system libraries — the
Dockerfile installs them via apt-get install libffi8 libgbm1 libgstreamer1.0-plugins-base … xwayland xauth. The proxy's pkg config
only bundles three libs as assets (libproxy-encoding.so,
libwayland-server.so.0, libwestfield.so) — these are the proxy's OWN
addon dependencies, not the apps'.
ASCII diagram — native app launch & connection
Browser (compositor-shell) Proxy host (Linux) App host (often same as proxy)
========================== ==================== ==============================
[1] User clicks app in shell $ compositor-proxy-cli \
│ └─── main.ts listens on :8081
│ sessionProcesses: Record<id, ChildProcess>
│
│ HTTP GET /gtk4-demo
│ Header: X-Compositor-Session-Id: <uuid>
├──────────────────────────────► │
│ │ if no child for <uuid>: fork(SessionProcess.ts)
│ │ └── IPC: {type:'start', payload:{config, sessionId}}
│ │ └── createSession() → NativeWaylandCompositorSession
│ │ ├── createDisplay()
│ │ │ └── wl_display_create() (libwayland-server)
│ │ ├── addSocketAuto()
│ │ │ └── creates $XDG_RUNTIME_DIR/wayland-N
│ │ │ returns "wayland-N" ◄── this becomes WAYLAND_DISPLAY
│ │ ├── initShm() + YUV420/NV12/YUV444/NV21
│ │ ├── initDrm("/dev/dri/renderD128")
│ │ │ └── westfield_egl_new() → EGLDisplay via GBM
│ │ │ wlr_linux_dmabuf_v1 global (zero-copy GPU buffers)
│ │ │ wlr_drm global (legacy dmabuf)
│ │ └── startPoll(wlDisplayFd, dispatchRequests)
│ │ └── uv_poll_t on EPOLLIN
│ │ (proxy-poll-addon.node)
│ │
│ │ IPC: {type:'launchApp', payload:{name, executable, args, env, serial}}
│ │ └── launchApplication() (NativeAppContext.ts)
│ │ ├── appEnv = {...process.env, ...env,
│ │ │ WAYLAND_DISPLAY: "wayland-N"}
│ │ ├── child_process.spawn("gtk4-demo", [], {env: appEnv})
│ │ │ ◄── posix_spawn/execvp, NO chroot, NO container
│ │ │ ──────────────────────────────► [2] gtk4-demo process starts
│ │ │ │
│ │ │ │ libwayland-client.so
│ │ │ │ connects to
│ │ │ │ $XDG_RUNTIME_DIR/wayland-N
│ │ │ │
│ │ │ ▼
│ │ │ ┌──────────────────────────┐
│ │ │ │ wl_display event loop │
│ │ │ │ (in proxy, libwayland- │
│ │ │ │ server.so.0) │
│ │ │ │ │
│ │ │ ◄── uv_poll fires, │ on_client_created → │
│ │ │ dispatchRequests() │ getCredentials(pid) │
│ │ │ wl_event_loop_dispatch() │ findMatchingNativeApp │
│ │ │ │ Context(pid) by PPID │
│ │ │ │ walk │
│ │ │ │ │
│ │ │ │ setWireMessageCallback │
│ │ │ │ setBufferCreated... │
│ │ │ │ setSyncDoneCallback │
│ │ │ │ setClientDestroyed... │
│ │ │ │ │
│ │ │ │ NativeWaylandClientSession
│ │ │ │ .messageInterceptor │
│ │ │ │ dispatches R<opcode> │
│ │ │ │ → {native, browser} │
│ │ │ └──────────────────────────┘
│ │ │
│ │ ├── child.on('spawn') → capture pid
│ │ │ createNativeAppContext(pid, name, external:false)
│ │ │ nativeAppContext.onDisconnect() // start 10-min SIGHUP timer
│ │ │
│ │ └── child.on('exit') → nativeAppContext.onExit({exitCode|signal})
│ │
│ │ IPC reply: {type:'launchAppSuccess', payload:{pid, key}}
│
│ HTTP 201 Created
│ Body: { baseURL, signalURL:"ws://…/signal?key=…", key, pid, name, internal:false }
◄────────────────────────────── │
│ │
│ Open WS: ws://…/signal?key=<16-hex>
├──────────────────────────────► │
│ │ SessionController demux /signal → nativeAppContext.onConnect(ws)
│ │ clears 10-min SIGHUP timer
│ │
│ │ For each new wl_client (one per wayland connection):
│ │ createProtocolChannel() → ARQChannel (KCP over WS)
│ │ sends CONNECT_CHANNEL {url:"ws://…/channel?id=…&key=…", desc}
│ ◄──── CONNECT_CHANNEL JSON ───┤
│ │
│ Open WS: ws://…/channel?id=…&key=…
├──────────────────────────────► │ binds WS to the ARQChannel
│ │
│ Wayland wire protocol flows over the ARQ channel (KCP reliable, 100ms interval):
│
│ App → wl_surface.commit ──── proxy intercepts R1 (attach) + R6 (commit) ────► browser compositor
│ App ← wl_surface.frame ──── proxy intercepts E1 (frame callback) ◄────── FrameFeedback paces callback
│ App → wl_display.sync ────── proxy fast-path sync (no roundtrip if no reply expected)
│ App ← wl_keyboard.enter ─── browser → proxy → sendEvents(wlClient, …) → flush(wlClient)
│
│ Buffer path (per wl_surface):
│ - App submits wl_buffer (dmabuf or shm)
│ - setBufferCreatedCallback → ProxyBuffer with creationSerial
│ - On commit: wl_surface_interceptor.R6 → Encoder.encodeFrame()
│ └── proxy-encoding-addon.encodeFrame(bufferId, contentSerial, creationSerial)
│ └── GStreamer pipeline (x264/nvh264/vaapih264) on dedicated GMainLoop thread
│ └── encoded H.264 sample → napi_threadsafe_function → Node main thread
│ └── sent over the surface's FRAME data channel (separate WS, also CONNECT_CHANNEL'd)
│
│ X11 path (xterm etc.):
│ - App connects to /tmp/.X11-unix/X1 (DISPLAY=:1, set by westfield_xwayland.c)
│ - First X connect → spawn_xserver() forks: execlp("Xwayland", …, "-rootless", "-wm", wm_fd)
│ - Xwayland itself becomes a Wayland client of the proxy (wl_client_create)
│ - When Xwayland ready (displayFd readable):
│ └── xtsb.nodeFDConnectionSetup(wmFd) → wraps as net.Socket
│ └── pipes through XWM data channel (4th channel type) to browser
│ └── browser-side xtsb speaks X11 → translates X windows → xdg_toplevel surfaces
│
│ Lifecycle:
│ - User closes browser tab → /signal WS closes with code 1001 → kill('SIGHUP')
│ - User clicks "force quit" in shell → /signal WS sends KILL_APP {signal:'SIGTERM'} → kill('SIGTERM')
│ └── if not exited in 10s → SIGKILL
│ - App crashes → child 'exit' event → nativeAppContext.onExit() → APP_TERMINATED msg → browser UI updates
│ - All clients gone for >1s → session.close() → process.exit() in SessionProcess.ts → main deletes entry
│
│ Native addon role summary:
│ - wayland-server-addon.node: full Wayland compositor (libwayland-server fork + westfield EGL/GBM/dmabuf/XWayland)
│ - proxy-poll-addon.node: libuv uv_poll_t hook so wayland FD events fire as Node callbacks
│ - proxy-encoding-addon.node: GStreamer H.264/PNG encoder on a dedicated GMainLoop thread, threadsafe_function back to Node
File reference
CLI (packages/compositor-proxy-cli/src/)
| File | Role |
|---|---|
main.ts |
HTTP + WS hub, forks per session |
main-controller.ts |
CLI arg parsing |
main-args.ts |
CLI arg definitions |
SessionProcess.ts |
Per-session child, IPC handler |
example-applications.json |
Pre-configured apps |
wait-until-ready.sh |
Startup readiness check |
Proxy TS layer (packages/compositor-proxy/src/)
| File | Role |
|---|---|
index.ts |
Exports createSession(), launchApplication() |
Session.ts |
Top-level proxy session |
SessionController.ts |
WS server with 9 endpoint paths |
NativeAppContext.ts |
Per-app state, signaling WS, kill timers |
NativeWaylandClientSession.ts |
Per-client message interceptor |
NativeWaylandCompositorSession.ts |
wl_display creation, initShm, initDrm |
Channel.ts |
ARQChannel + SimpleChannel |
kcp.ts |
~1100-line KCP implementation |
FrameFeedback.ts |
Receives 4-byte decode feedback, paces wl_frame.done |
ProxyBuffer.ts |
Proxy-side buffer representation |
SurfaceBufferEncoding.ts |
Encoding pipeline orchestration |
XWaylandSession.ts |
XWayland spawn + bridge |
wayland-server.ts |
TS wrapper around wayland-server-addon.node |
io/ |
WebSocket server glue |
encoding/ |
High-level encoding orchestration |
protocol/ |
Generated interceptor modules |
addons/ |
.node file shims (Linux-x64) |
config.ts |
Encoding-related configuration |
Proxy C layer (packages/compositor-proxy/native/)
| Path | Role |
|---|---|
CMakeLists.txt |
Build config |
wayland/src/addon.c |
Node-API addon: wayland-server-addon.node |
wayland/src/westfield-buffer.c |
Buffer helpers |
wayland/src/westfield-dmabuf.c |
DMA-BUF import via EGL/GBM |
wayland/src/westfield-egl.c |
EGL zero-copy setup |
wayland/src/westfield-xwayland.c |
XWayland spawn + bridge |
wayland/src/wlr_drm.c |
wl_drm global impl |
wayland/src/wlr_linux_dmabuf_v1.c |
zwp_linux_dmabuf_v1 global impl |
wayland/src/drm-protocol.c |
DRM protocol helpers |
wayland/src/linux-dmabuf-unstable-v1-protocol.c |
linux-dmabuf-v1 protocol stubs |
wayland/src/wayland-server/ |
Vendored + patched libwayland-server (with changes.diff) |
encoding/src/gst_frame_encoder.c |
GStreamer H.264/PNG encoder |
encoding/src/gst_main_loop.c |
Dedicated GLib main loop for encoding |
encoding/src/node_encoder.c |
Node-API addon: proxy-encoding-addon.node |
poll/src/poll.c |
libuv uv_poll_t wrapper |
node-api/ |
Vendored Node-API headers |
XWayland support (libs/xtsb/)
| File | Role |
|---|---|
README.md |
Project intro |
package.json |
@gfld/xtsb package config |
protocol/xproto.xml |
X11 core protocol XML (5800+ lines) |
protocol/shape.xml |
Shape extension XML |
protocol/xfixes.xml |
XFIXES extension XML |
protocol/render.xml |
Render extension XML |
protocol/composite.xml |
Composite extension XML |
src/ |
Generated TS bindings |
Next actions for user (Odoo 20 integration)
- Build the proxy on a Linux host (or use
udevbe/compositor-proxy-cliDocker image). - Write an
applications.jsonmapping desired apps to HTTP paths. - Start proxy with
--allow-origin=https://your-odoo.example.com. - Embed the Greenfield
compositor-shellPreact SPA in Odoo (iframe or QWeb template) — OR replace it with your Odoo 20 OWL shell (see10-porting-to-odoo-js-python.md). - Trigger app launches from Odoo via
fetch('http://<proxy>:8081/<path>', { headers: { 'X-Compositor-Session-Id': sessionId }})— Odoo frontend JS uses the returnedsignalURLto open the per-app control WS. - For multi-tenant isolation: one
SessionProcessper browser session is automatic, but file-system isolation between tenants requires separate proxy instances or containers per tenant. - For GPU acceleration: pass
--device /dev/dri:/dev/drito Docker; use--encoder=nvh264|vaapih264|x264based on GPU.