Files

24 KiB
Raw Permalink Blame History

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 — creates Session + SessionController
  • launchApp — spawns the actual app binary
  • wsUpgrade — 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 real wl_display via createDisplay(), calls addSocketAuto() which creates the UNIX socket at $XDG_RUNTIME_DIR/wayland-N and returns the name wayland-N (this becomes WAYLAND_DISPLAY). Calls initShm() (adds YUV420/NV12/YUV444/ NV21 formats) and initDrm() (opens /dev/dri/renderD128 via EGL/GBM, registers zwp_linux_dmabuf_v1 + wl_drm globals). Integrates the wl_display FD into Node's libuv loop via startPoll().
  • NativeWaylandClientSession — per-client wire-message interceptor. Uses MessageInterceptor + auto-generated per-resource interceptor modules (src/protocol/wl_*_interceptor.js). Each request returns {native, browser, neverReplies?} routing decision. Handles wl_display.sync fast-path (skips browser round-trip when no reply expected). Marshalls FDs as ProxyFD JSON.
  • 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. The wm_fd is bridged via xtsb to the browser, which implements the actual XWM logic.
  • Channel — ARQChannel (KCP-over-WebSocket, reliable, low-latency) for PROTOCOL/FRAME/XWM; SimpleChannel for FEEDBACK.

App launch flow (definitive answer)

  1. Browser sends GET http://proxy:8081/<path> with X-Compositor-Session-Id: <uuid> header.
  2. Main process forks a SessionProcess if none exists for that session ID, sends start IPC.
  3. Main sends launchApp IPC with {name, executable, args, env, serial}.
  4. SessionProcess calls launchApplication():
    • appEnv = {...process.env, ...env, WAYLAND_DISPLAY: session.nativeWaylandCompositorSession.waylandDisplay}
    • child_process.spawn(executable, args, {env: appEnv}) — plain spawn, no chroot/container
  5. On spawn event, captures pid, creates NativeAppContext, registers exit listener.
  6. Replies to main with {pid, key} → main returns HTTP 201 with {baseURL, signalURL, key, pid, name}.
  7. Browser opens signalURL as WebSocket → control channel established.
  8. App connects to $XDG_RUNTIME_DIR/wayland-N → libwayland fires on_client_created → proxy looks up PID via getCredentials(), walks /proc/<pid>/status PPid chain to match a NativeAppContext.
  9. For each new wl_client, creates a PROTOCOL ARQ channel and sends CONNECT_CHANNEL over the signaling WS; browser opens a new WS at /channel?id=….
  10. 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_cb
  • wl_client_set_registry_created_cb
  • wl_client_set_sync_done_cb
  • wl_client_add_resource_created_listener
  • wl_display_set_global_created_cb
  • wl_resource_destroy_silently
  • wl_get_server_object_ids_batch
  • wl_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)

  1. Creates /tmp/.X11-unix/X<N> listener socket
  2. setenv("DISPLAY", ":<N>", 1)
  3. Waits for X client connect
  4. On connect: socketpair(AF_UNIX) × 3 (wayland / wm / display)
  5. wl_client_create(wl_display, sv[0]) — Xwayland itself becomes a Wayland client of the proxy!
  6. 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)

  1. Build the proxy on a Linux host (or use udevbe/compositor-proxy-cli Docker image).
  2. Write an applications.json mapping desired apps to HTTP paths.
  3. Start proxy with --allow-origin=https://your-odoo.example.com.
  4. Embed the Greenfield compositor-shell Preact SPA in Odoo (iframe or QWeb template) — OR replace it with your Odoo 20 OWL shell (see 10-porting-to-odoo-js-python.md).
  5. Trigger app launches from Odoo via fetch('http://<proxy>:8081/<path>', { headers: { 'X-Compositor-Session-Id': sessionId }}) — Odoo frontend JS uses the returned signalURL to open the per-app control WS.
  6. For multi-tenant isolation: one SessionProcess per browser session is automatic, but file-system isolation between tenants requires separate proxy instances or containers per tenant.
  7. For GPU acceleration: pass --device /dev/dri:/dev/dri to Docker; use --encoder=nvh264|vaapih264|x264 based on GPU.