34 KiB
10 — Porting Blueprint (JS Browser + Python Host + Odoo 20 OWL Shell)
This is the standalone porting chapter. It maps every Greenfield layer analyzed in chapters 02-09 to concrete guidance for building a Wayland-to-HTML compositor that integrates with an Odoo 20 addon shell and a JS + Python backend.
Target architecture
The recommended architecture:
┌──────────────────────────────────────────────────────────────────────────────┐
│ BROWSER (Odoo 20 web client) │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ Odoo 20 OWL Shell addon (your code) │ │
│ │ - Application launcher (lists apps from /api/compositor/apps) │ │
│ │ - Window management UI (taskbar, decorations) │ │
│ │ - Keyboard layout picker (RMLVO) │ │
│ │ - Per-output <canvas> element │ │
│ │ - Triggers app launches via Odoo /api/compositor/launch │ │
│ │ - Implements UserShellApi.events.* callbacks │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ userShell.events + userShell.actions │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ @gfld/compositor (browser-side compositor library) │ │
│ │ - Use as-is from npm: @gfld/compositor │ │
│ │ - Replaces compositor-shell with createOdooUserShellApi(session) │ │
│ │ - Renders surfaces to <canvas> via WebGL │ │
│ │ - Manages keyboard/pointer/touch │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
│
│ WebSocket (TCP, KCP-over-WS)
│ 9 endpoint paths per session
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ PYTHON HOST (Linux server) │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ Odoo 20 backend addon (your code) │ │
│ │ - /api/compositor/apps → list of available apps │ │
│ │ - /api/compositor/launch → triggers proxy to spawn app │ │
│ │ - Session management (one Odoo session ↔ one proxy session) │ │
│ │ - Per-tenant isolation, ACL, audit log │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ spawn / IPC │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ Python compositor-proxy (your code — replaces @gfld/compositor-proxy) │ │
│ │ - aiohttp/FastAPI WebSocket server (replaces ws + ws-server) │ │
│ │ - pywayland (cffi bindings to libwayland-server) │ │
│ │ - python-xkbcommon for keymap compilation │ │
│ │ - GStreamer Python bindings (gi.repository.Gst) for H.264 encoding │ │
│ │ - asyncio event loop integrating libwayland FD polling │ │
│ │ - Per-app child_process spawning (subprocess.Popen with env) │ │
│ │ - Sandboxing via bubblewrap + seccomp (NEW — Greenfield has none) │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ WAYLAND_DISPLAY=wayland-N │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ Native Linux apps (gtk4-demo, xterm, kwrite, ...) │ │
│ │ - Use host system libraries (no sysroot) │ │
│ │ - Sandboxed via bubblewrap + seccomp │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
Per-layer porting guidance
For each Greenfield layer analyzed in chapters 02-09:
Layer 1: Wayland Protocol XML
What to copy as-is: All four protocol XML files (wayland.xml,
xdg-shell.xml, wayland-drm.xml, web-bitmapbuf-unstable-v1.xml).
They are upstream standard + Greenfield's custom extension. No need to
rewrite.
What to replace: Nothing. The XML is the source of truth.
Where Python fits: Python doesn't need to parse the XML directly —
use pywayland which already ships with parsed bindings for the standard
protocols. For Greenfield's custom web-bitmapbuf-unstable-v1, you can
either skip it (native apps don't use it) or generate Python bindings via
pywayland-protocol-scanner.
Where the Odoo 20 OWL shell hooks in: Not applicable. This layer is below the shell.
Layer 2: Protocol Generators
What to copy as-is: The TypeScript generators for the browser side
(client-generator, compositor-generator). They produce stubs for the
browser compositor and browser-resident JS apps.
What to replace: The compositor-proxy-generator. Greenfield's proxy
generates CommonJS *_interface.js + *_interceptor.js files for the
Node N-API addon. For Python, you don't need a generator — use
pywayland's built-in protocol bindings.
Where Python fits: pywayland provides wl_interface structs and
request/event dispatch in pure Python via cffi. You write Python classes
that implement WlCompositorRequests, WlSeatRequests, etc.
Where the Odoo 20 OWL shell hooks in: Not applicable.
Layer 3: Wayland Runtime
What to copy as-is: @gfld/common (Connection.ts) and
@gfld/compositor-protocol for the browser side. These implement the
standard Wayland wire format and object lifecycle, and they're MIT-licensed.
What to replace: The proxy's wayland-server.ts (which wraps the
wayland-server-addon.node N-API addon). Replace with pywayland's
Display and Client classes.
Where Python fits: pywayland provides:
pywayland.server.Display— wrapswl_displaypywayland.server.Client— wrapswl_clientpywayland.server.Resource— wrapswl_resourcepywayland.server.Global— wrapswl_global- Per-resource event/request dispatch via Python method overrides
You write Python classes that subclass WlCompositor, WlSeat, etc. —
similar to how Greenfield's TypeScript classes implement *Requests
interfaces.
Where the Odoo 20 OWL shell hooks in: Not applicable. The runtime layer is below the shell.
Layer 4: Transport
What to copy as-is: The browser side of Channel.ts (ARQChannel
SimpleChannel+ReconnectingWebSocket) andkcp.ts. The TypeScript KCP implementation is solid and battle-tested.
What to replace: The native side of Channel.ts. Replace Node's ws
library with aiohttp WebSocket or FastAPI WebSocket.
Where Python fits:
- Use
aiohttp.web.WebSocketResponseorstarlette.websockets.WebSocketfor the WebSocket server. - Port the KCP implementation from TypeScript to Python (or wrap a C
implementation via cffi —
pykcpexists). - Use
asyncioevent loop integration:pywaylandexposes the libwayland event-loop FD, which you can integrate viaasyncio.loop.add_reader(). - For multiplexing 9 endpoints, use
aiohttp.web.Applicationwith 9 routes, orFastAPIwith 9 WebSocket endpoints.
Where the Odoo 20 OWL shell hooks in: The shell connects to the proxy via the same WebSocket URLs. No change needed.
Layer 5: Rendering Pipeline (browser side)
What to copy as-is: The entire packages/compositor/src/ rendering
pipeline. This is the heart of Greenfield and it's where most of the
engineering effort went. Reuse it wholesale:
Surface.ts,View.ts,Output.ts,Loop.ts,Callback.ts,Region.ts,Shm.ts,BufferContents.ts,BufferImplementation.ts,ImageBitmapBuffer.tsrender/directory (Renderer, Scene, SceneShader, Texture, RenderState, shaders)remote/directory (StreamingBuffer, BufferStream, EncodedFrame, EncoderFeedback, decoders)
What to replace: Nothing. This layer is pure browser TypeScript and works as-is.
Where Python fits: Not applicable. The browser does all rendering.
Where the Odoo 20 OWL shell hooks in: The shell owns the
<canvas> element and passes it to
userShell.actions.initScene(canvasCreator). The compositor creates a
Scene per canvas.
Layer 6: Surface Lifecycle / Roles
What to copy as-is: All of XdgWmBase.ts, XdgSurface.ts,
XdgToplevel.ts, XdgPopup.ts, XdgPositioner.ts, Shell.ts,
ShellSurface.ts, Subcompositor.ts, Subsurface.ts, SurfaceRole.ts,
SurfaceChild.ts. These implement Wayland's role state machines correctly.
What to replace: Nothing.
Where Python fits: Not applicable (this is browser-side).
Where the Odoo 20 OWL shell hooks in: The shell implements
UserShellApi.events callbacks (see 08-app-lifecycle.md). Specifically:
surfaceCreated→ add a window to the OWL taskbarsurfaceDestroyed→ remove from taskbarsurfaceTitleUpdated→ update window titlesurfaceActivationUpdated→ mark window as activeclientCreated/clientDestroyed→ update app registryclientUnresponsiveUpdated→ show "not responding" indicator
Layer 7: Desktop / Layout Policy
What to copy as-is: desktop/Desktop.ts provides Floating and
AlwaysFullScreen modes. Floating is what most users want (windows can be
moved/resized freely).
What to replace: If your Odoo shell needs custom layout policies
(e.g. tiling, tabbed), you'll need to extend DesktopSurface with new
modes. But you can start with Floating.
Where Python fits: Not applicable.
Where the Odoo 20 OWL shell hooks in: The shell calls
userShell.actions.activateSurface(compositorSurface) when the user
clicks a taskbar entry.
Layer 8: User Shell (the integration seam)
What to copy as-is: UserShellApi.ts. The interface is clean and
shell-agnostic.
What to replace: Replace compositor-shell (Preact) with your Odoo
20 OWL addon.
Where Python fits: Not applicable. The shell is browser-side.
Where the Odoo 20 OWL shell hooks in: This IS the integration point. Create an OWL component that:
// Pseudocode for Odoo 20 OWL shell
import { Component, useState, onMounted } from "@odoo/owl";
import { createCompositorSession } from "@gfld/compositor";
export class CompositorShell extends Component {
setup() {
this.state = useState({
surfaces: new Map(), // id → { title, appId, active }
clients: new Map(),
});
onMounted(async () => {
const session = createCompositorSession();
this.session = session;
// Wire UserShellApi events to OWL reactive state
session.userShell.events.surfaceCreated = (surface) => {
this.state.surfaces.set(surface.id, {
title: "", appId: "", active: false
});
};
session.userShell.events.surfaceDestroyed = (surface) => {
this.state.surfaces.delete(surface.id);
};
session.userShell.events.surfaceTitleUpdated = (surface, title) => {
const s = this.state.surfaces.get(surface.id);
if (s) s.title = title;
};
session.userShell.events.surfaceActivationUpdated = (surface, active) => {
const s = this.state.surfaces.get(surface.id);
if (s) s.active = active;
};
// ... etc for clientCreated, clientDestroyed, etc.
// Initialize the canvas
const canvas = this.canvasRef.el;
session.userShell.actions.initScene(() => ({
canvas,
width: canvas.clientWidth,
height: canvas.clientHeight,
}));
});
}
// User clicks app in launcher
async launchApp(appId) {
const response = await fetch("/web/compositor/launch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ app_id: appId }),
});
const { signal_url } = await response.json();
// Browser JS opens signal_url WebSocket → compositor handles the rest
}
// User clicks taskbar entry
activateSurface(surfaceId) {
this.session.userShell.actions.activateSurface({ id: surfaceId });
}
}
Odoo 20 backend addon
The Odoo 20 backend addon acts as a thin proxy between the OWL shell and the Python compositor-proxy. It handles:
- App launcher config (per-user, per-tenant allowed apps list)
- Auth (Odoo session → compositor-proxy session mapping)
- Audit log (which user launched which app when)
- Multi-tenant isolation (separate Python proxy processes per tenant, containerized)
- Per-tenant resource limits (max concurrent apps, max memory)
# Pseudocode for Odoo 20 backend controller
from odoo import http
from odoo.http import request
class CompositorController(http.Controller):
@http.route("/web/compositor/apps", type="json", auth="user")
def list_apps(self):
# Return user-allowed apps from applications.json
user = request.env.user
return request.env["compositor.app"].search_read(
[("allowed_group_ids", "in", user.groups_id.ids)],
["name", "icon", "app_id"],
)
@http.route("/web/compositor/launch", type="json", auth="user")
def launch_app(self, app_id):
user = request.env.user
# Look up the user's compositor-proxy host (per-tenant routing)
proxy_host = user.compositor_proxy_host
proxy_port = user.compositor_proxy_port
# Tell the Python proxy to spawn the app
# The proxy returns a signal_url + auth key
import aiohttp
async with aiohttp.ClientSession() as sess:
async with sess.post(
f"http://{proxy_host}:{proxy_port}/launch",
json={
"app_id": app_id,
"odoo_session_id": request.session.sid,
"user_id": user.id,
},
) as resp:
result = await resp.json()
# Audit log
request.env["compositor.audit.log"].create({
"user_id": user.id,
"app_id": app_id,
"timestamp": fields.Datetime.now(),
})
return {"signal_url": result["signal_url"]}
Python compositor-proxy (the big new code)
This is the bulk of the new code. Replace Node's compositor-proxy +
compositor-proxy-cli with a Python equivalent. The key components:
# Pseudocode for Python compositor-proxy
import asyncio
import os
import signal
import subprocess
from aiohttp import web, WSMsgType
from pywayland.server import Display
from xkbcommon import xkb
import gi
gi.require_version("Gst", "1.0")
gi.require_version("GstGL", "1.0")
from gi.repository import Gst, GstGL, GLib
class CompositorProxy:
def __init__(self, bind_ip="0.0.0.0", bind_port=8081, render_device="/dev/dri/renderD128"):
self.bind_ip = bind_ip
self.bind_port = bind_port
self.render_device = render_device
self.display = Display()
self.sessions = {} # session_id → SessionProcess
async def start(self):
# 1. Create wl_display + add socket
self.display.create()
socket_name = self.display.add_socket_auto()
os.environ["WAYLAND_DISPLAY"] = socket_name
# 2. Init SHM formats (YUV420, NV12, YUV444, NV21)
self.init_shm()
# 3. Init DRM (open /dev/dri/renderD128, register dmabuf globals)
self.init_drm()
# 4. Integrate wl_display FD into asyncio loop
loop = asyncio.get_event_loop()
loop.add_reader(self.display.get_fd(), self.dispatch_requests)
# 5. Start GStreamer
Gst.init(None)
self.gst_loop = GLib.MainLoop()
# Run GLib loop in a separate thread (GStreamer requires it)
import threading
threading.Thread(target=self.gst_loop.run, daemon=True).start()
# 6. Start aiohttp server
app = web.Application()
app.add_routes([
web.get("/signal", self.handle_signal),
web.get("/channel", self.handle_channel),
web.post("/launch", self.handle_launch),
# ... 6 more endpoints
])
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, self.bind_ip, self.bind_port)
await site.start()
# Run forever
await asyncio.Event().wait()
def dispatch_requests(self):
"""Called by asyncio when wl_display FD is readable."""
self.display.dispatch_events_pending()
async def handle_launch(self, request):
body = await request.json()
app_id = body["app_id"]
# Spawn the app, set WAYLAND_DISPLAY
env = {**os.environ, "WAYLAND_DISPLAY": os.environ["WAYLAND_DISPLAY"]}
proc = subprocess.Popen(
self.applications[app_id]["executable"],
env=env,
# NEW vs Greenfield: sandbox with bubblewrap
preexec_fn=lambda: self.setup_sandbox(),
)
return web.json_response({
"signal_url": f"ws://{self.bind_ip}:{self.bind_port}/signal?app_id={app_id}",
"pid": proc.pid,
})
def setup_sandbox(self):
"""NEW: bubblewrap + seccomp sandbox (Greenfield has none)."""
import ctypes
libc = ctypes.CDLL("libc.so.6")
# ... bwrap / unshare / seccomp_filter setup
Native app bridge (Python equivalent)
For each Wayland client that connects (one per app), spawn a Python
NativeWaylandClientSession equivalent that:
- Sets up the wire-message interceptor (using pywayland's resource dispatch).
- Creates a
PROTOCOLARQ channel (portkcp.tsto Python or usepykcp). - Creates a
FRAMEARQ channel for the surface's encoded content. - Manages the app's lifecycle (10-min SIGHUP grace, SIGTERM escalation).
For XWayland, replace westfield-xwayland.c with a Python equivalent
that:
- Creates
/tmp/.X11-unix/X<N>listener socket. - Sets
DISPLAY=:Nenvironment variable. - Spawns
Xwayland :N -ac -rootless -listen <fd> -displayfd <fd> -wm <fd>. - Wraps the
wm_fdas a Python file-like object. - Uses
xtsb(the existing TypeScript X11 bindings) on the browser side to handle XWM logic — the browser side stays the same.
Hardware acceleration (Python equivalent)
For the encoding pipeline:
- Use GStreamer Python bindings (
gi.repository.Gst):appsrcfor input buffersglupload,glcolorconvert,glshaderfor GPU processingx264enc/nvh264enc/vaapih264encfor H.264 encodingappsinkfor output samples
- Same encoder selection logic as Greenfield (PNG ≤ 256×256, H.264 above).
- Run the GLib main loop in a separate thread, use
asyncio.run_in_executorfor cross-thread encoding requests. - For zero-copy dmabuf, use
GstGLMemoryEGL(same as Greenfield).
For the browser decode side: no change. The browser uses WebCodecs preferred + FFmpeg Wasm fallback, same as Greenfield.
Key trade-offs vs staying on Node
Staying on Node (use Greenfield as-is, just replace compositor-shell with Odoo)
Pros:
- ~90% of Greenfield's code reused as-is (browser compositor + native proxy)
- Less code to write/maintain
- Faster time-to-market
- Battle-tested in Greenfield's rc1 cycle
Cons:
- Native Node addons (
.nodefiles) are Linux-x64 only, ABI-locked to Node major - GStreamer + libwayland integration is via C N-API, harder to extend
- Odoo team may have more Python expertise than Node expertise
- AGPL-3.0 license on
compositor-proxy— viral to your modifications
Switching to Python
Pros:
- Aligns with Odoo's Python backend stack
pywayland(cffi) is easier to debug than N-API addons- Python GStreamer bindings are well-documented
- Easier to integrate with Odoo's auth/session model
- Can avoid AGPL by reimplementing from scratch (or use only MIT libs)
Cons:
- Significant new code (rewrite the proxy + native addons in Python)
- Python GIL may bottleneck encoding throughput (use multiprocessing or run GStreamer in separate thread)
- pywayland is less mature than libwayland's native bindings
- You take on the maintenance of a forked Greenfield-equivalent
Recommendation: hybrid approach
- Browser side: Use Greenfield's
@gfld/compositorlibrary as-is (MIT-licensed via@gfld/compositor-protocol+@gfld/common, AGPL only on@gfld/compositoritself — but the shell is yours so the AGPL virality is contained to the compositor library). - Native side: Start with Greenfield's
compositor-proxy(AGPL) to get to MVP fast. Then progressively port to Python as needs evolve (sandboxing, multi-tenant, custom auth, Odoo session integration). - Shell: Build the Odoo 20 OWL addon from scratch (clean room, MIT/Apache license).
Implementation phases
Phase 1 (1-2 weeks): MVP — display a single Linux app
- Run Greenfield's compositor-proxy-cli as a Docker container.
- Build minimal Odoo 20 OWL addon that creates a
<canvas>anduserShell.eventshandlers. - Embed the OWL addon in an Odoo action.
- Trigger app launch via HTTP fetch to the proxy.
- Verify the Linux app displays in the canvas.
Phase 2 (2-4 weeks): Multi-app, basic window management
- Implement
surfaceCreated/surfaceDestroyedhandlers. - Build a taskbar OWL component listing active surfaces.
- Implement
activateSurfaceaction on taskbar click. - Test with 2-3 concurrent apps.
Phase 3 (4-8 weeks): Python port of proxy
- Replace Node
compositor-proxy-cliwith a Python equivalent. - Implement WebSocket endpoints in
aiohttporFastAPI. - Port the KCP TypeScript implementation to Python (or use
pykcp). - Replace
wayland-server-addon.nodewithpywayland. - Replace
proxy-encoding-addon.nodewith GStreamer Python bindings. - Add bubblewrap sandboxing (NEW — Greenfield has none).
Phase 4 (8-16 weeks): Polish & scale
- Multi-tenant isolation (separate Python proxy processes per tenant).
- Per-tenant resource limits (cgroups, max memory, max CPU).
- Audit log integration with Odoo.
- Clipboard sync (native → DOM and reverse).
- Touch input implementation (Greenfield has stub only).
- Primary selection (middle-click paste).
- Adaptive bitrate (consume
EncoderFeedbackdata, Greenfield doesn't).
Specific replacements for each Greenfield native component
| Greenfield native component | Python replacement | Notes |
|---|---|---|
wayland-server-addon.node (1231 lines C) |
pywayland (cffi bindings) |
Same libwayland-server, Python wrapper |
proxy-poll-addon.node (127 lines C) |
asyncio.loop.add_reader() |
Built-in, no addon needed |
proxy-encoding-addon.node + libproxy-encoding.so (~1700 lines C) |
gi.repository.Gst (GStreamer Python bindings) |
Same GStreamer, Python wrapper |
Patched libwayland-server (vendored at native/wayland/src/wayland-server/) |
Use upstream libwayland-server + Greenfield's patches applied | Apply changes.diff to a fork of libwayland, install via system package |
westfield-*.c (EGL, dmabuf, xwayland helpers) |
Reimplement in Python via pyEGL or python-xkbcommon + pygobject's GstGL |
Less mature than Greenfield's C — be prepared to debug |
xtsb (X11 TypeScript bindings) |
Use as-is on browser side, replace Python side with python-xlib |
Browser-side xtsb is MIT-licensed |
Node ws library |
aiohttp.web.WebSocketResponse or starlette.websockets.WebSocket |
Mature, well-documented |
Node pkg (single-binary packaging) |
PyInstaller or shiv |
Same end result |
Node fork() per session |
multiprocessing.Process or os.fork() |
Direct equivalent |
compositor-proxy-cli (CLI args) |
argparse |
Standard Python |
ajv (JSON schema validation) |
jsonschema |
Standard Python |
Final architecture diagram (target state)
┌──────────────────────────────────────────────────────────────────────────────────┐
│ BROWSER (Odoo 20 web client) │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ Odoo 20 OWL Shell addon (your code, MIT/Apache) │ │
│ │ - Application launcher, window manager, keyboard picker │ │
│ │ - Per-output <canvas> │ │
│ │ - Implements UserShellApi.events.* callbacks │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ userShell.events + actions │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ @gfld/compositor (browser-side, MIT libs + AGPL compositor) │ │
│ │ - Surface, View, Output, Loop, Seat, Keyboard, Pointer, Touch │ │
│ │ - render/ (WebGL shaders) │ │
│ │ - remote/ (Channel, KCP, decoders) │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
│
│ WebSocket + KCP-over-WS
│ 9 endpoint paths per session
▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│ PYTHON HOST (Linux server) │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ Odoo 20 backend addon (your code, MPL-2.0/LGPL) │ │
│ │ - /web/compositor/apps, /web/compositor/launch │ │
│ │ - Per-user, per-tenant allowed apps list │ │
│ │ - Audit log │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ subprocess / IPC │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ Python compositor-proxy (your code, MIT/Apache) │ │
│ │ - aiohttp/FastAPI WebSocket server (9 endpoints) │ │
│ │ - pywayland (cffi bindings to libwayland-server) │ │
│ │ - python-xkbcommon for keymap compilation │ │
│ │ - GStreamer Python bindings for H.264 encoding │ │
│ │ - asyncio event loop integrating libwayland FD polling │ │
│ │ - Per-app subprocess.Popen with bubblewrap sandbox │ │
│ │ - KCP port (pykcp or hand-rolled) │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ WAYLAND_DISPLAY=wayland-N │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ Native Linux apps (gtk4-demo, xterm, kwrite, ...) │ │
│ │ - Use host system libraries │ │
│ │ - Sandboxed via bubblewrap + seccomp │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘