645 lines
34 KiB
Markdown
645 lines
34 KiB
Markdown
# 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` — wraps `wl_display`
|
||
- `pywayland.server.Client` — wraps `wl_client`
|
||
- `pywayland.server.Resource` — wraps `wl_resource`
|
||
- `pywayland.server.Global` — wraps `wl_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`) and `kcp.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.WebSocketResponse` or `starlette.websockets.WebSocket`
|
||
for the WebSocket server.
|
||
- Port the KCP implementation from TypeScript to Python (or wrap a C
|
||
implementation via cffi — `pykcp` exists).
|
||
- Use `asyncio` event loop integration: `pywayland` exposes the
|
||
libwayland event-loop FD, which you can integrate via
|
||
`asyncio.loop.add_reader()`.
|
||
- For multiplexing 9 endpoints, use `aiohttp.web.Application` with 9
|
||
routes, or `FastAPI` with 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.ts`
|
||
- `render/` 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 taskbar
|
||
- `surfaceDestroyed` → remove from taskbar
|
||
- `surfaceTitleUpdated` → update window title
|
||
- `surfaceActivationUpdated` → mark window as active
|
||
- `clientCreated` / `clientDestroyed` → update app registry
|
||
- `clientUnresponsiveUpdated` → 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:
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```python
|
||
# 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:
|
||
|
||
```python
|
||
# 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:
|
||
|
||
1. Sets up the wire-message interceptor (using pywayland's resource
|
||
dispatch).
|
||
2. Creates a `PROTOCOL` ARQ channel (port `kcp.ts` to Python or use
|
||
`pykcp`).
|
||
3. Creates a `FRAME` ARQ channel for the surface's encoded content.
|
||
4. Manages the app's lifecycle (10-min SIGHUP grace, SIGTERM escalation).
|
||
|
||
For XWayland, replace `westfield-xwayland.c` with a Python equivalent
|
||
that:
|
||
|
||
1. Creates `/tmp/.X11-unix/X<N>` listener socket.
|
||
2. Sets `DISPLAY=:N` environment variable.
|
||
3. Spawns `Xwayland :N -ac -rootless -listen <fd> -displayfd <fd> -wm <fd>`.
|
||
4. Wraps the `wm_fd` as a Python file-like object.
|
||
5. 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`):
|
||
- `appsrc` for input buffers
|
||
- `glupload`, `glcolorconvert`, `glshader` for GPU processing
|
||
- `x264enc` / `nvh264enc` / `vaapih264enc` for H.264 encoding
|
||
- `appsink` for 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_executor`
|
||
for 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 (`.node` files) 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
|
||
|
||
1. **Browser side**: Use Greenfield's `@gfld/compositor` library as-is
|
||
(MIT-licensed via `@gfld/compositor-protocol` + `@gfld/common`, AGPL only
|
||
on `@gfld/compositor` itself — but the shell is yours so the AGPL
|
||
virality is contained to the compositor library).
|
||
2. **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).
|
||
3. **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
|
||
|
||
1. Run Greenfield's compositor-proxy-cli as a Docker container.
|
||
2. Build minimal Odoo 20 OWL addon that creates a `<canvas>` and
|
||
`userShell.events` handlers.
|
||
3. Embed the OWL addon in an Odoo action.
|
||
4. Trigger app launch via HTTP fetch to the proxy.
|
||
5. Verify the Linux app displays in the canvas.
|
||
|
||
### Phase 2 (2-4 weeks): Multi-app, basic window management
|
||
|
||
1. Implement `surfaceCreated` / `surfaceDestroyed` handlers.
|
||
2. Build a taskbar OWL component listing active surfaces.
|
||
3. Implement `activateSurface` action on taskbar click.
|
||
4. Test with 2-3 concurrent apps.
|
||
|
||
### Phase 3 (4-8 weeks): Python port of proxy
|
||
|
||
1. Replace Node `compositor-proxy-cli` with a Python equivalent.
|
||
2. Implement WebSocket endpoints in `aiohttp` or `FastAPI`.
|
||
3. Port the KCP TypeScript implementation to Python (or use `pykcp`).
|
||
4. Replace `wayland-server-addon.node` with `pywayland`.
|
||
5. Replace `proxy-encoding-addon.node` with GStreamer Python bindings.
|
||
6. Add bubblewrap sandboxing (NEW — Greenfield has none).
|
||
|
||
### Phase 4 (8-16 weeks): Polish & scale
|
||
|
||
1. Multi-tenant isolation (separate Python proxy processes per tenant).
|
||
2. Per-tenant resource limits (cgroups, max memory, max CPU).
|
||
3. Audit log integration with Odoo.
|
||
4. Clipboard sync (native → DOM and reverse).
|
||
5. Touch input implementation (Greenfield has stub only).
|
||
6. Primary selection (middle-click paste).
|
||
7. Adaptive bitrate (consume `EncoderFeedback` data, 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 │ │
|
||
│ └────────────────────────────────────────────────────────────────────────────┘ │
|
||
└──────────────────────────────────────────────────────────────────────────────────┘
|
||
```
|