# 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=` (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/` with `X-Compositor-Session-Id: ` 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//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` listener socket 2. `setenv("DISPLAY", ":", 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", ":", "-ac", "-rootless", "-listen", "", "-displayfd", "", "-wm", "", 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 │ │ HTTP GET /gtk4-demo │ Header: X-Compositor-Session-Id: ├──────────────────────────────► │ │ │ if no child for : 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 │ │ │ │ │ → {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://:8081/', { 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.