Initial commit: one_os_compositor Odoo 20 addon — core OWL library + backend models + JWT auth

Includes:
- compositor.app, compositor.session, compositor.session.app, compositor.audit.log models
- /web/compositor/{token,apps,launch,audit} HTTP controllers with JWT (HS256)
- CompositorService OWL service (reusable via useService('compositor'))
- CompositorCanvas OWL component (reusable canvas wrapper)
- Security: compositor_group_user, compositor_group_manager groups + ir.access.csv rules
- 5 demo apps pre-loaded (gnome-calculator, xterm, gtk4-demo, glxgears, featherpad)
- Audit log capturing JWT issuance + app launch events
- Stub mode: launching apps returns a stub SurfaceHandle when proxy isn't connected
- Tested against Odoo 20 + PostgreSQL 17

Author: 1bspace.com
Parent company: 1B Global
License: Proprietary (1B Space internal use)
This commit is contained in:
1b-agent committed 2026-10-08 08:48:46 +00:00
commit 93a5a0a221
32 files changed
+2497

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Python
__pycache__/
*.pyc
*.pyo
.venv/
venv/
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
# Logs
*.log
# Build
node_modules/
dist/
build/
*.egg-info/
+98
View File
@@ -0,0 +1,98 @@
# 1B Space Compositor Core (`one_os_compositor`)
> The foundation of the 1B Space Wayland-to-HTML5 compositor. Provides
> reusable OWL library components + Odoo backend models for running real
> Linux applications and displaying them natively inside the Odoo web client.
## What this addon provides
### Backend (Python)
- **`compositor.app`** — Application catalog (one record per Linux app you can
launch, e.g. `gnome-calculator`, `xterm`, `kwrite`).
- **`compositor.session`** — Per-browser-tab compositor session (one per active
OWL shell instance). Inherits `mail.thread` for tracking.
- **`compositor.audit.log`** — Append-only audit log (launch, terminate, crash
events). Cannot be modified or deleted via the UI.
- **Controllers**:
- `POST /web/compositor/token` — Issue a 15-min JWT (HS256) for the proxy.
- `GET /web/compositor/apps` — List apps the current user is allowed to launch.
- `POST /web/compositor/launch` — Tell the proxy to spawn a sandboxed app.
- `POST /web/compositor/terminate` — Tell the proxy to kill an app.
- `POST /web/compositor/audit` — Ingestest endpoint called by the proxy.
- **Security**: groups `Compositor / User` and `Compositor / Manager`, with
`ir.rule` so users only see their own sessions.
### Frontend (OWL library)
- **`@one_os_compositor/services/compositor_service`** — Reusable OWL service
that wraps the WebSocket connection to the compositor proxy, manages JWT
refresh, and exposes a clean API for embedding surfaces anywhere.
- **`@one_os_compositor/components/compositor_canvas`** — Reusable OWL
component that creates a `<canvas>` element and binds it to a compositor
session. Drop it anywhere in Odoo (action, form view, dashboard widget).
- **`@one_os_compositor/components/compositor_surface`** — Wraps a single
Wayland surface as an embeddable component (e.g. "show calculator here").
Other addons can `import` these and use them like any other OWL service/component.
## Quick start (dev)
1. Install the addon:
```bash
# Start Odoo dev server (auto-reload on save)
cd /home/z/my-project/odoo-dev
./start-odoo.sh
```
2. Open http://localhost:8069, log in as `admin` / `admin`.
3. Go to **Apps → Update Apps List** (activate developer mode first).
4. Search for "1B Space Compositor Core", click **Install**.
5. Go to **1B OS → Compositor** — you'll see the app catalog. Click any app
to launch it (currently stubbed — the Python proxy is in the sister addon
`one_os_compositor_proxy`).
## Architecture
See `../../greenfield-analysis/` and `../../mit-compositor-analysis/` for the
full architectural analysis that informed this addon's design.
### Three-addon family
```
one_os_compositor ← this addon (core OWL library + backend models)
└─ one_os_compositor_shell ← full-screen desktop shell (taskbar, launcher)
└─ one_os_compositor_proxy ← Python proxy bridge (aiohttp + pywayland + GStreamer)
```
### Reusability
Other 1B OS addons can use the compositor like this:
```js
import { compositorService } from "@one_os_compositor/services/compositor_service";
export class MyDashboard extends Component {
setup() {
this.compositor = useService("compositor");
}
async launchApp(appId) {
// Spawns a sandboxed Linux app and embeds it as a Wayland surface
const surface = await this.compositor.launchApp(appId);
surface.attachTo(this.canvasRef.el);
}
}
```
## License
Proprietary — 1B Space internal use only.
- **Author**: 1bspace.com
- **Parent company**: 1B Global
- **Maintainer**: dev@1bspace.com
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""1B Space Compositor Core addon.
This package contains the backend models, controllers, and the OWL library
service/component registrations for the compositor.
Public Python API:
- :class:`compositor.app` — application catalog model
- :class:`compositor.session` — per-browser-tab session (mail.thread mixin)
- :class:`compositor.audit.log` — append-only audit log
- :class:`CompositorController` — HTTP controllers
Public JS API (via ``@one_os_compositor/*`` imports):
- ``@one_os_compositor/services/compositor_service`` — main service
- ``@one_os_compositor/components/compositor_canvas`` — canvas component
- ``@one_os_compositor/components/compositor_surface`` — single surface wrapper
"""
from . import models
from . import controllers
def _post_init_hook(env):
"""Called after the addon is installed.
Sets the default JWT secret if not already configured. The secret
is stored in ``ir.config_parameter`` so it can be rotated without
restarting Odoo.
:param env: Odoo environment.
"""
config_param = env["ir.config_parameter"]
if not config_param.get_str("compositor.jwt_secret"):
# Auto-generate a strong secret on first install
import secrets
secret = secrets.token_urlsafe(48)
config_param.set_str("compositor.jwt_secret", secret)
env["ir.logging"].sudo().create({
"name": "one_os_compositor",
"type": "server",
"level": "INFO",
"message": "Compositor JWT secret auto-generated on first install.",
"dbname": env.cr.dbname,
})
+106
View File
@@ -0,0 +1,106 @@
{
"name": "1B Space Compositor Core",
"version": "20.0.1.0.0",
"category": "1B Global/OS",
"summary": "Wayland-to-HTML5 compositor core — OWL library + backend models",
"description": """
1B Space Compositor Core
========================
This addon is the **foundation** of the 1B Space Compositor — a fully proprietary
Wayland-to-HTML5 compositor that runs real Linux applications and displays them
natively inside the Odoo web client.
Architecture
------------
- **Backend models**: ``compositor.app``, ``compositor.session``, ``compositor.audit.log``
- **Controllers**: ``/web/compositor/{token,apps,launch,terminate,audit}``
- **OWL library**: ``CompositorService`` — reusable JS service that other addons
can import and use to embed a Wayland compositor surface anywhere in the Odoo
web client.
- **Auth**: JWT (HS256) issued by Odoo backend, validated by the Python
compositor proxy (separate process).
This addon is **reusable**: other 1B OS addons can depend on ``one_os_compositor``
and import its OWL components/services via ``@one_os_compositor/core``.
Sister addons
-------------
- ``one_os_compositor_shell``: the full-screen desktop shell UI (taskbar,
launcher, window manager).
- ``one_os_compositor_proxy``: backend bridge to the Python compositor proxy
(separate aiohttp process running pywayland + GStreamer + bubblewrap).
Author & License
----------------
- Author: 1bspace.com
- Company: 1B Global
- License: Proprietary (1B Space internal use)
Tech stack
----------
- Browser: TypeScript + OWL 3.x + WebGL2 + WebCodecs + Web Workers + OffscreenCanvas
- Host (in sister addon): Python + aiohttp + pywayland + PyGObject/GStreamer +
bubblewrap + seccomp
- Transport: WebSocket + KCP-over-WebSocket (reliable UDP-over-TCP)
- Auth: JWT HS256, 15-min expiry, signed by Odoo backend
See ``README.md`` for the full architecture and the ``greenfield-analysis`` /
``mit-compositor-analysis`` documents (in the 1B Space internal docs) for
the deep technical background.
""",
"author": "1bspace.com",
"website": "https://1bspace.com",
"maintainer": "1B Global <dev@1bspace.com>",
"license": "Other proprietary",
"depends": ["base", "web", "bus", "mail"],
"excludes": [],
"data": [
"security/compositor_security.xml",
"security/ir.access.csv",
"data/compositor_app_data.xml",
"data/ir_sequence_data.xml",
"data/ir_config_parameter_data.xml",
"models/compositor_app_views.xml",
"models/compositor_session_views.xml",
"models/compositor_audit_log_views.xml",
"views/ir_actions_client_views.xml",
"views/compositor_menu.xml",
],
"demo": ["demo/compositor_app_demo.xml"],
"assets": {
"web.assets_backend": [
"one_os_compositor/static/src/lib/**/*.js",
"one_os_compositor/static/src/lib/**/*.mjs",
"one_os_compositor/static/src/services/**/*.js",
"one_os_compositor/static/src/services/**/*.mjs",
"one_os_compositor/static/src/components/**/*.js",
"one_os_compositor/static/src/components/**/*.mjs",
"one_os_compositor/static/src/components/**/*.xml",
"one_os_compositor/static/src/scss/compositor.scss",
],
"web.assets_backend_minimal": [
"one_os_compositor/static/src/lib/**/*.js",
"one_os_compositor/static/src/lib/**/*.mjs",
"one_os_compositor/static/src/services/**/*.js",
"one_os_compositor/static/src/services/**/*.mjs",
],
"web.qunit_suite_tests": [
"one_os_compositor/static/tests/**/*.js",
],
},
"qweb": [],
"installable": True,
"application": True,
"auto_install": False,
"pre_init_hook": None,
"post_init_hook": "_post_init_hook",
"post_load": None,
"external_dependencies": {
"python": ["jwt"],
"bin": [],
},
"assets_version": "20.0.1.0.0",
"live_test_url": "/compositor",
"summary_login_user_field": None,
}
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
"""Compositor HTTP controllers."""
from . import main
+308
View File
@@ -0,0 +1,308 @@
# -*- coding: utf-8 -*-
"""1B Space Compositor HTTP controllers.
All compositor-related endpoints live under ``/web/compositor/*`` so they're
easy to route through Nginx (single-origin HTTPS via
``location /compositor/ { proxy_pass http://127.0.0.1:8081; }``).
"""
import datetime
import logging
import secrets
from odoo import http, fields
from odoo.http import request
from odoo.exceptions import UserError, AccessError
_logger = logging.getLogger(__name__)
try:
import jwt
except ImportError: # pragma: no cover
jwt = None
_logger.warning("PyJWT not installed — compositor auth will fail. "
"Install with: pip install pyjwt")
class CompositorController(http.Controller):
"""All compositor HTTP endpoints.
The OWL shell calls these endpoints (via ``rpc`` service) to:
1. Get a JWT (``/web/compositor/token``)
2. List available apps (``/web/compositor/apps``)
3. Launch an app (``/web/compositor/launch``)
4. Terminate an app (``/web/compositor/terminate``)
5. Ingest audit events from the proxy (``/web/compositor/audit``)
The actual compositor proxy (Python aiohttp server, running pywayland +
GStreamer + bubblewrap) lives in the sister addon ``one_os_compositor_proxy``.
"""
# ──────────────────────────────────────────────────────────────────────
# JWT issuance
# ──────────────────────────────────────────────────────────────────────
@http.route("/web/compositor/token", type="json", auth="user", methods=["POST"])
def get_token(self):
"""Issue a 15-min JWT (HS256) for the compositor proxy.
:return: ``{jwt, proxy_url, expires_at, compositor_session_id}``
:raises UserError: if JWT secret is not configured
"""
if jwt is None:
raise UserError("PyJWT is not installed in Odoo's environment.")
secret = request.env["ir.config_parameter"].sudo().get_str("compositor.jwt_secret")
if not secret:
raise UserError(
"Compositor JWT secret is not configured. "
"Set ir.config_parameter 'compositor.jwt_secret'."
)
user = request.env.user
# Reuse existing session for this user if they have one active, else create.
Session = request.env["compositor.session"]
session = Session.search([
("user_id", "=", user.id),
("state", "in", ["pending", "active", "disconnected"]),
], order="started_at desc", limit=1)
if not session:
session = Session.create({
"user_id": user.id,
"state": "pending",
})
session.touch()
# Compose JWT payload
now = datetime.datetime.utcnow()
expires_at = now + datetime.timedelta(minutes=15)
payload = {
"iss": request.httprequest.host,
"aud": "compositor-proxy",
"sub": user.id,
"db": request.db,
"company_ids": user.company_ids.ids,
"scope": "compositor",
"compositor_session_id": session.compositor_session_id,
"iat": now,
"exp": expires_at,
}
token = jwt.encode(payload, secret, algorithm="HS256")
# Log issuance
request.env["compositor.audit.log"].sudo().log_event(
"jwt_issued",
user_id=user.id,
session_id=session.id,
client_ip=request.httprequest.remote_addr,
user_agent=request.httprequest.environ.get("HTTP_USER_AGENT", ""),
)
# Update session with new JWT info
session.write({
"last_jwt_issued_at": fields.Datetime.to_datetime(now),
"last_jwt_expires_at": fields.Datetime.to_datetime(expires_at),
"state": "active",
})
proxy_url = request.env["ir.config_parameter"].sudo().get_str(
"compositor.proxy_url",
default="http://localhost:8081",
)
return {
"jwt": token,
"proxy_url": proxy_url,
"expires_at": expires_at.isoformat() + "Z",
"compositor_session_id": session.compositor_session_id,
"session_db_id": session.id,
}
# ──────────────────────────────────────────────────────────────────────
# App catalog
# ──────────────────────────────────────────────────────────────────────
@http.route("/web/compositor/apps", type="json", auth="user", methods=["POST"])
def list_apps(self):
"""List apps the current user is allowed to launch.
Filters by:
- ``active = True``
- ``allowed_group_ids`` empty (visible to all internal users) OR
current user is in one of the allowed groups
- Apps visible in the current user's company
"""
user = request.env.user
domain = [
("active", "=", True),
"|",
("allowed_group_ids", "=", False),
("allowed_group_ids", "in", user.group_ids.ids),
]
apps = request.env["compositor.app"].search_read(
domain,
["app_id", "name", "description", "icon", "color", "running_session_count"],
)
# icon is base64 — leave as-is, the OWL shell will use it directly
return {"apps": apps}
# ──────────────────────────────────────────────────────────────────────
# App launch / terminate
# ──────────────────────────────────────────────────────────────────────
@http.route("/web/compositor/launch", type="json", auth="user", methods=["POST"])
def launch_app(self, app_id):
"""Tell the compositor proxy to spawn a sandboxed app.
:param app_id: The ``compositor.app.app_id`` (string identifier)
:return: ``{signal_url, pid, compositor_surface_id, app_instance_db_id}``
"""
user = request.env.user
app = request.env["compositor.app"].search([
("app_id", "=", app_id),
"|",
("allowed_group_ids", "=", False),
("allowed_group_ids", "in", user.group_ids.ids),
], limit=1)
if not app:
raise AccessError(f"You don't have access to launch {app_id!r}")
# Get or create the user's active session
session = request.env["compositor.session"].search([
("user_id", "=", user.id),
("state", "in", ["pending", "active", "disconnected"]),
], order="started_at desc", limit=1)
if not session:
raise UserError("No active compositor session. Call /web/compositor/token first.")
# Create an app-instance record (state=launching)
inst = request.env["compositor.session.app"].create({
"session_id": session.id,
"app_id": app.id,
"state": "launching",
"title": app.name,
})
# Build the launch config to send to the proxy
launch_config = app.get_launch_config()
launch_config.update({
"compositor_session_id": session.compositor_session_id,
"app_instance_db_id": inst.id,
"odoo_user_id": user.id,
"odoo_db": request.db,
})
# In the full impl: POST this to the Python compositor proxy via
# aiohttp. For now (proxy not yet built), return a stub response
# that the OWL shell can use to develop against.
# TODO: replace with real HTTP call once one_os_compositor_proxy is built
try:
proxy_response = self._call_proxy_launch(launch_config)
except NotImplementedError:
# Proxy not yet connected — return a stub so the OWL shell can develop
_logger.info("Compositor proxy not yet connected — returning stub response for %s", app_id)
proxy_response = {
"signal_url": None,
"pid": -1,
"compositor_surface_id": f"stub-{inst.id}",
"stub": True,
}
# Update the app-instance record
inst.write({
"state": "running" if not proxy_response.get("stub") else "launching",
"pid": proxy_response.get("pid", -1),
"signal_url": proxy_response.get("signal_url"),
"compositor_surface_id": proxy_response.get("compositor_surface_id"),
})
# Audit log
request.env["compositor.audit.log"].sudo().log_event(
"app_launch",
user_id=user.id,
session_id=session.id,
app_id=app.id,
app_instance_id=inst.id,
pid=proxy_response.get("pid"),
)
return {
"signal_url": proxy_response.get("signal_url"),
"pid": proxy_response.get("pid"),
"compositor_surface_id": proxy_response.get("compositor_surface_id"),
"app_instance_db_id": inst.id,
"stub": proxy_response.get("stub", False),
}
@http.route("/web/compositor/terminate", type="json", auth="user", methods=["POST"])
def terminate_app(self, app_instance_db_id, force=False):
"""Tell the compositor proxy to terminate an app.
:param app_instance_db_id: The ID of the ``compositor.session.app`` record
:param force: If True, send SIGKILL instead of SIGTERM
"""
user = request.env.user
inst = request.env["compositor.session.app"].browse(app_instance_db_id)
if not inst.exists() or inst.session_id.user_id != user:
raise AccessError("You don't have access to terminate this app instance.")
# TODO: call the proxy to actually kill the process
inst.write({
"state": "terminated",
"ended_at": fields.Datetime.now(),
"signal_received": "SIGKILL" if force else "SIGTERM",
})
request.env["compositor.audit.log"].sudo().log_event(
"app_force_kill" if force else "app_terminate",
user_id=user.id,
session_id=inst.session_id.id,
app_id=inst.app_id.id,
app_instance_id=inst.id,
pid=inst.pid,
)
return {"ok": True}
# ──────────────────────────────────────────────────────────────────────
# Audit ingestion (called by the proxy)
# ──────────────────────────────────────────────────────────────────────
@http.route("/web/compositor/audit", type="json", auth="user", methods=["POST"])
def audit_callback(self, **kwargs):
"""Ingest audit events from the Python compositor proxy.
The proxy calls this endpoint to report app crashes, exits, etc.
:param kwargs: Field values for the new ``compositor.audit.log`` record
:return: ``{"ok": True}``
"""
request.env["compositor.audit.log"].sudo().log_event(
kwargs.get("event_type", "app_crash"),
user_id=kwargs.get("user_id"),
session_id=kwargs.get("session_id"),
app_id=kwargs.get("app_id"),
app_instance_id=kwargs.get("app_instance_id"),
pid=kwargs.get("pid"),
exit_code=kwargs.get("exit_code"),
signal_received=kwargs.get("signal"),
detail=kwargs.get("detail"),
client_ip=kwargs.get("client_ip"),
)
return {"ok": True}
# ──────────────────────────────────────────────────────────────────────
# Internal: proxy call
# ──────────────────────────────────────────────────────────────────────
def _call_proxy_launch(self, launch_config):
"""Send the launch request to the Python compositor proxy.
Returns the proxy's response dict, or raises if the proxy is
unreachable.
The actual implementation is in ``one_os_compositor_proxy``.
This stub raises ``NotImplementedError`` so the controller returns
a stub response that the OWL shell can develop against.
"""
raise NotImplementedError(
"Compositor proxy is not yet connected. "
"Install the one_os_compositor_proxy addon to enable app launching."
)
# Note: `fields` is imported at the top of this file.
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- A few common Linux apps pre-loaded for dev/testing.
These will only launch if the binary exists on the proxy host. -->
<record id="compositor_app_xterm" model="compositor.app">
<field name="name">XTerm</field>
<field name="app_id">xterm</field>
<field name="executable">/usr/bin/xterm</field>
<field name="description">Standard X11 terminal emulator. Requires XWayland.</field>
<field name="memory_limit_mb">256</field>
<field name="cpu_quota_percent">100</field>
<field name="tasks_max">64</field>
<field name="sandbox_network">False</field>
<field name="sandbox_gpu">False</field>
<field name="color">#000000</field>
</record>
<record id="compositor_app_gnome_calculator" model="compositor.app">
<field name="name">GNOME Calculator</field>
<field name="app_id">gnome-calculator</field>
<field name="executable">/usr/bin/gnome-calculator</field>
<field name="description">Standard GNOME calculator. Wayland-native.</field>
<field name="memory_limit_mb">256</field>
<field name="cpu_quota_percent">100</field>
<field name="tasks_max">64</field>
<field name="sandbox_network">False</field>
<field name="sandbox_gpu">True</field>
<field name="color">#3584e4</field>
</record>
<record id="compositor_app_gtk4_demo" model="compositor.app">
<field name="name">GTK4 Demo</field>
<field name="app_id">gtk4-demo</field>
<field name="executable">/usr/bin/gtk4-demo</field>
<field name="description">GTK4 widget demo. Wayland-native. Good for testing GL + alpha.</field>
<field name="memory_limit_mb">512</field>
<field name="cpu_quota_percent">200</field>
<field name="tasks_max">128</field>
<field name="sandbox_network">False</field>
<field name="sandbox_gpu">True</field>
<field name="color">#6f42c1</field>
</record>
<record id="compositor_app_glxgears" model="compositor.app">
<field name="name">GLX Gears</field>
<field name="app_id">glxgears</field>
<field name="executable">/usr/bin/glxgears</field>
<field name="description">Mesa GL demo. Tests GPU rendering + DMA-BUF path. Requires XWayland.</field>
<field name="memory_limit_mb">256</field>
<field name="cpu_quota_percent">200</field>
<field name="tasks_max">64</field>
<field name="sandbox_network">False</field>
<field name="sandbox_gpu">True</field>
<field name="color">#ff6b35</field>
</record>
<record id="compositor_app_featherpad" model="compositor.app">
<field name="name">FeatherPad</field>
<field name="app_id">featherpad</field>
<field name="executable">/usr/bin/featherpad</field>
<field name="description">Lightweight Qt text editor. Wayland-native.</field>
<field name="memory_limit_mb">256</field>
<field name="cpu_quota_percent">100</field>
<field name="tasks_max">64</field>
<field name="sandbox_network">False</field>
<field name="sandbox_gpu">True</field>
<field name="color">#28a745</field>
</record>
</data>
</odoo>
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Default compositor config parameters -->
<record id="config_param_proxy_url" model="ir.config_parameter">
<field name="key">compositor.proxy_url</field>
<field name="value">http://localhost:8081</field>
</record>
<record id="config_param_encoder" model="ir.config_parameter">
<field name="key">compositor.encoder</field>
<field name="value">vaapih264</field>
</record>
<record id="config_param_render_device" model="ir.config_parameter">
<field name="key">compositor.render_device</field>
<field name="value">/dev/dri/renderD128</field>
</record>
<!-- JWT secret is auto-generated in post_init_hook, but we set a
dev placeholder here so the addon works out of the box. -->
<record id="config_param_jwt_secret_dev" model="ir.config_parameter">
<field name="key">compositor.jwt_secret</field>
<field name="value">dev-secret-change-in-production-1bspace-compositor</field>
</record>
<!-- Session timeout (10 minutes, matches Greenfield's SIGHUP grace) -->
<record id="config_param_session_timeout_seconds" model="ir.config_parameter">
<field name="key">compositor.session_timeout_seconds</field>
<field name="value">600</field>
</record>
<!-- App kill timer escalation (10s SIGTERM → SIGKILL) -->
<record id="config_param_kill_escalation_seconds" model="ir.config_parameter">
<field name="key">compositor.kill_escalation_seconds</field>
<field name="value">10</field>
</record>
</data>
</odoo>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Sequence for compositor.app.app_id — generates 'APP-0001', 'APP-0002', etc. -->
<record id="seq_compositor_app" model="ir.sequence">
<field name="name">Compositor App ID</field>
<field name="code">compositor.app</field>
<field name="prefix">APP-</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Demo: assign all apps to "Internal Users" group so any logged-in user can launch them -->
<record id="compositor_app_xterm" model="compositor.app">
<field name="allowed_group_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="compositor_app_gnome_calculator" model="compositor.app">
<field name="allowed_group_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="compositor_app_gtk4_demo" model="compositor.app">
<field name="allowed_group_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="compositor_app_glxgears" model="compositor.app">
<field name="allowed_group_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="compositor_app_featherpad" model="compositor.app">
<field name="allowed_group_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
</data>
</odoo>
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""Compositor backend models."""
from . import compositor_app
from . import compositor_session
from . import compositor_audit_log
+229
View File
@@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
"""``compositor.app`` — Application catalog.
Each record represents one Linux application that can be launched and
displayed inside the Odoo web client.
"""
import logging
import os
import shutil
from odoo import api, fields, models
from odoo.exceptions import ValidationError, UserError
_logger = logging.getLogger(__name__)
class CompositorApp(models.Model):
_name = "compositor.app"
_description = "1B Space Compositor Application"
_order = "name"
_rec_name = "name"
# ─── Identity ────────────────────────────────────────────────────────────
name = fields.Char(
string="Application Name",
required=True,
translate=True,
help="Human-readable name shown in the launcher.",
)
app_id = fields.Char(
string="Application ID",
required=True,
copy=False,
help="Unique identifier (e.g. 'gnome-calculator'). Used by the proxy "
"to look up the executable when the user clicks 'Launch'.",
)
description = fields.Text(
string="Description",
translate=True,
)
# ─── Executable ────────────────────────────────────────────────────────
executable = fields.Char(
string="Executable Path",
required=True,
help="Absolute path to the binary, e.g. '/usr/bin/gnome-calculator'. "
"The path must exist on the compositor proxy host, NOT on the "
"Odoo server (they may be different machines).",
)
args = fields.Char(
string="Command-line Arguments",
help="Space-separated arguments passed to the executable. "
"Use shell-safe quoting; the proxy parses via shlex.split.",
)
env_vars = fields.Text(
string="Environment Variables",
help="One VAR=value per line. Applied on top of the proxy's default env. "
"WAYLAND_DISPLAY is set automatically by the proxy.",
)
# ─── Resource limits (per-instance) ────────────────────────────────────
memory_limit_mb = fields.Integer(
string="Memory Limit (MB)",
default=1024,
required=True,
help="Maximum RSS for the sandboxed app. Enforced via systemd-run --scope "
"with MemoryMax. Default 1024 MB.",
)
cpu_quota_percent = fields.Integer(
string="CPU Quota (%)",
default=200,
required=True,
help="CPU quota. 100% = 1 core. Default 200% (2 cores). "
"Enforced via systemd-run --scope with CPUQuota.",
)
tasks_max = fields.Integer(
string="Max Tasks (PIDs)",
default=256,
required=True,
help="Maximum number of processes/threads. Default 256. "
"Enforced via systemd-run --scope with TasksMax.",
)
# ─── Sandbox flags ─────────────────────────────────────────────────────
sandbox_network = fields.Boolean(
string="Allow Network Access",
default=False,
help="If checked, the app is allowed network access (veth + nftables "
"filter). Default unchecked — most GTK apps don't need network.",
)
sandbox_gpu = fields.Boolean(
string="Allow GPU Access",
default=True,
help="If checked, the app gets read-write access to /dev/dri/renderD128. "
"Required for GL apps (glxgears, gtk4-demo).",
)
# ─── Access control ────────────────────────────────────────────────────
allowed_group_ids = fields.Many2many(
"res.groups",
"compositor_app_group_rel",
"app_id",
"gid",
string="Allowed Groups",
help="Only users in one of these groups can see and launch this app. "
"Empty = all internal users.",
)
# ─── UI ────────────────────────────────────────────────────────────────
icon = fields.Image(
string="Icon",
help="Application icon (PNG, 256x256 recommended).",
)
color = fields.Char(
string="Accent Color",
default="#6f42c1",
help="Color used for the app tile in the launcher.",
)
# ─── Lifecycle ─────────────────────────────────────────────────────────
active = fields.Boolean(
string="Active",
default=True,
help="If unchecked, the app is hidden from the launcher but not deleted.",
)
company_id = fields.Many2one(
"res.company",
string="Company",
default=lambda self: self.env.company,
)
# ─── Counts ─────────────────────────────────────────────────────────────
running_session_count = fields.Integer(
string="Currently Running",
compute="_compute_running_session_count",
help="Number of currently-active instances of this app across all sessions.",
)
@api.depends("app_id")
def _compute_running_session_count(self):
SessionAppInst = self.env["compositor.session.app"]
for app in self:
app.running_session_count = SessionAppInst.search_count([
("app_id", "=", app.id),
("state", "=", "running"),
])
# ─── Constraints ───────────────────────────────────────────────────────
@api.constrains("app_id")
def _check_app_id_unique(self):
for app in self:
if self.search_count([("app_id", "=", app.app_id), ("id", "!=", app.id)]):
raise ValidationError("Application ID must be unique.")
@api.constrains("memory_limit_mb", "cpu_quota_percent", "tasks_max")
def _check_limits(self):
for app in self:
if app.memory_limit_mb < 64:
raise ValidationError("Memory limit must be at least 64 MB.")
if app.cpu_quota_percent < 50:
raise ValidationError("CPU quota must be at least 50%.")
if app.tasks_max < 16:
raise ValidationError("Max tasks must be at least 16.")
# ─── Public methods ────────────────────────────────────────────────────
def get_launch_config(self):
"""Return the launch config dict sent to the Python compositor proxy.
Called by ``CompositorController.launch_app`` when the user clicks
"Launch" in the OWL shell. The proxy uses this to spawn the app
with bubblewrap + seccomp + systemd-run.
"""
self.ensure_one()
env_vars = {}
if self.env_vars:
for line in self.env_vars.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise UserError(f"Invalid env var line: {line!r}")
key, _, val = line.partition("=")
env_vars[key.strip()] = val.strip()
return {
"app_id": self.app_id,
"name": self.name,
"executable": self.executable,
"args": self.args or "",
"env": env_vars,
"memory_limit_mb": self.memory_limit_mb,
"cpu_quota_percent": self.cpu_quota_percent,
"tasks_max": self.tasks_max,
"sandbox_network": self.sandbox_network,
"sandbox_gpu": self.sandbox_gpu,
}
def action_view_running_sessions(self):
"""Open the sessions view filtered to this app's running instances."""
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": f"Running {self.name} sessions",
"res_model": "compositor.session.app",
"view_mode": "list,form",
"domain": [("app_id", "=", self.id), ("state", "=", "running")],
}
def action_test_executable(self):
"""Check if the executable exists on this Odoo host.
NOTE: This is informational only — the executable must exist on the
COMPOSITOR PROXY host (which may be a different machine). For dev,
they're often the same.
"""
self.ensure_one()
if shutil.which(self.executable.split()[0]) or os.path.exists(self.executable):
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": "Executable found",
"message": f"{self.executable} is accessible on this host.",
"type": "success",
"sticky": False,
},
}
raise UserError(
f"Executable {self.executable!r} not found on this Odoo host. "
f"If your compositor proxy runs on a different machine, this is OK."
)
+112
View File
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- ─── App List View ─────────────────────────────────────────── -->
<record id="view_compositor_app_list" model="ir.ui.view">
<field name="name">compositor.app.list</field>
<field name="model">compositor.app</field>
<field name="arch" type="xml">
<list string="Compositor Apps">
<field name="app_id"/>
<field name="name"/>
<field name="executable"/>
<field name="memory_limit_mb" string="Mem (MB)"/>
<field name="cpu_quota_percent" string="CPU (%)"/>
<field name="sandbox_network" string="Net"/>
<field name="sandbox_gpu" string="GPU"/>
<field name="running_session_count" string="Running"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ─── App Form View ─────────────────────────────────────────── -->
<record id="view_compositor_app_form" model="ir.ui.view">
<field name="name">compositor.app.form</field>
<field name="model">compositor.app</field>
<field name="arch" type="xml">
<form string="Compositor App">
<header>
<button name="action_test_executable" string="Test Executable" type="object" class="btn-secondary"/>
<button name="action_view_running_sessions" string="View Running Sessions" type="object" class="btn-primary"/>
</header>
<sheet>
<field name="icon" widget="image" class="oe_avatar"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. GNOME Calculator"/></h1>
<h3><field name="app_id" placeholder="e.g. gnome-calculator"/></h3>
</div>
<group>
<group string="Executable">
<field name="executable" placeholder="/usr/bin/gnome-calculator"/>
<field name="args" placeholder="--new-window"/>
<field name="env_vars" placeholder="GDK_SCALE=1"/>
</group>
<group string="Resource Limits (per instance)">
<field name="memory_limit_mb"/>
<field name="cpu_quota_percent"/>
<field name="tasks_max"/>
</group>
</group>
<group>
<group string="Sandbox">
<field name="sandbox_network"/>
<field name="sandbox_gpu"/>
</group>
<group string="Display">
<field name="color" widget="color"/>
<field name="active" widget="boolean_toggle"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Allowed Groups" name="allowed_groups">
<field name="allowed_group_ids" widget="many2many_tags"/>
</page>
<page string="Description" name="description">
<field name="description" widget="html"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ─── App Search View ───────────────────────────────────────── -->
<record id="view_compositor_app_search" model="ir.ui.view">
<field name="name">compositor.app.search</field>
<field name="model">compositor.app</field>
<field name="arch" type="xml">
<search string="Compositor Apps">
<field name="name"/>
<field name="app_id"/>
<field name="executable"/>
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
<filter name="with_network" string="Has Network Access" domain="[('sandbox_network', '=', True)]"/>
<filter name="with_gpu" string="Has GPU Access" domain="[('sandbox_gpu', '=', True)]"/>
<separator/>
<filter name="group_app_id" string="App ID" context="{'group_by': 'app_id'}"/>
</search>
</field>
</record>
<!-- ─── Action ────────────────────────────────────────────────── -->
<record id="action_compositor_app" model="ir.actions.act_window">
<field name="name">Apps</field>
<field name="res_model">compositor.app</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_compositor_app_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new compositor app
</p>
<p>
Each app is a Linux application that can be launched and
displayed inside the Odoo web client via the 1B Space
Compositor.
</p>
</field>
</record>
</data>
</odoo>
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""``compositor.audit.log`` — Append-only audit log.
Records every compositor event (app launch, terminate, crash, session close)
for compliance, debugging, and security forensics.
Records CANNOT be modified or deleted via the UI. Only superuser can
directly delete via SQL (e.g. for archival).
"""
from odoo import api, fields, models
from odoo.exceptions import UserError
class CompositorAuditLog(models.Model):
_name = "compositor.audit.log"
_description = "1B Space Compositor Audit Log (append-only)"
_order = "create_date desc"
_rec_name = "create_date"
# ─── Identity ────────────────────────────────────────────────────────────
user_id = fields.Many2one(
"res.users",
string="User",
required=True,
index=True,
)
session_id = fields.Many2one(
"compositor.session",
string="Session",
index=True,
)
app_id = fields.Many2one(
"compositor.app",
string="Application",
index=True,
)
app_instance_id = fields.Many2one(
"compositor.session.app",
string="App Instance",
index=True,
)
# ─── Event ──────────────────────────────────────────────────────────────
event_type = fields.Selection(
[
("session_start", "Session Started"),
("session_close", "Session Closed"),
("session_reconnect", "Session Reconnected"),
("session_timeout", "Session Timed Out (SIGHUP)"),
("app_launch", "App Launched"),
("app_terminate", "App Terminated"),
("app_crash", "App Crashed"),
("app_force_kill", "App Force-Killed"),
("jwt_issued", "JWT Issued"),
("jwt_validation_failed", "JWT Validation Failed"),
("proxy_started", "Proxy Started"),
("proxy_crashed", "Proxy Crashed"),
],
string="Event Type",
required=True,
index=True,
)
# ─── Detail ─────────────────────────────────────────────────────────────
pid = fields.Integer(string="PID")
exit_code = fields.Integer(string="Exit Code")
signal_received = fields.Char(string="Signal Received")
detail = fields.Text(string="Detail")
client_ip = fields.Char(string="Client IP")
user_agent = fields.Char(string="User Agent")
# ─── Lifecycle ──────────────────────────────────────────────────────────
create_date = fields.Datetime(
string="Timestamp",
default=fields.Datetime.now,
readonly=True,
index=True,
)
# ─── Append-only enforcement ────────────────────────────────────────────
def write(self, vals):
if not self.env.context.get("compositor_allow_audit_write"):
raise UserError(
"Audit logs are append-only and cannot be modified.\n\n"
"To fix a mistake, create a new corrective log entry with "
"context={'compositor_allow_audit_write': True} (admin only)."
)
return super().write(vals)
def unlink(self):
if not self.env.user.has_group("base.group_system"):
raise UserError(
"Audit logs can only be deleted by system administrators."
)
return super().unlink()
# ─── Public methods ────────────────────────────────────────────────────
@api.model
def log_event(self, event_type, **kwargs):
"""Convenience method to add a log entry.
:param event_type: One of the event_type selection values.
:param kwargs: Field values for the new record.
:return: The created record.
"""
defaults = {
"user_id": self.env.user.id,
"create_date": fields.Datetime.now(),
}
defaults.update(kwargs)
# Bypass append-only check since this is the create path
return self.sudo().with_context(compositor_allow_audit_write=True).create({
"event_type": event_type,
**defaults,
})
+100
View File
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- ─── Audit Log List View ───────────────────────────────────── -->
<record id="view_compositor_audit_log_list" model="ir.ui.view">
<field name="name">compositor.audit.log.list</field>
<field name="model">compositor.audit.log</field>
<field name="arch" type="xml">
<list string="Compositor Audit Log" create="0" edit="0" delete="false">
<field name="create_date" string="Timestamp"/>
<field name="event_type" widget="badge"
decoration-success="event_type in ['session_start', 'app_launch', 'jwt_issued', 'proxy_started', 'session_reconnect']"
decoration-info="event_type == 'app_terminate'"
decoration-warning="event_type in ['session_close', 'app_force_kill']"
decoration-danger="event_type in ['app_crash', 'session_timeout', 'proxy_crashed', 'jwt_validation_failed']"/>
<field name="user_id"/>
<field name="app_id"/>
<field name="pid"/>
<field name="exit_code"/>
<field name="signal_received"/>
<field name="client_ip"/>
<field name="detail"/>
</list>
</field>
</record>
<!-- ─── Audit Log Form View ───────────────────────────────────── -->
<record id="view_compositor_audit_log_form" model="ir.ui.view">
<field name="name">compositor.audit.log.form</field>
<field name="model">compositor.audit.log</field>
<field name="arch" type="xml">
<form string="Audit Log Entry" create="0" edit="0" delete="false">
<sheet>
<group>
<group>
<field name="create_date" readonly="1"/>
<field name="event_type" readonly="1"/>
<field name="user_id" readonly="1"/>
</group>
<group>
<field name="session_id" readonly="1"/>
<field name="app_id" readonly="1"/>
<field name="app_instance_id" readonly="1"/>
</group>
</group>
<group>
<group string="Process Info">
<field name="pid" readonly="1"/>
<field name="exit_code" readonly="1"/>
<field name="signal_received" readonly="1"/>
</group>
<group string="Network">
<field name="client_ip" readonly="1"/>
<field name="user_agent" readonly="1"/>
</group>
</group>
<group string="Detail">
<field name="detail" readonly="1" widget="text"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ─── Audit Log Search View ─────────────────────────────────── -->
<record id="view_compositor_audit_log_search" model="ir.ui.view">
<field name="name">compositor.audit.log.search</field>
<field name="model">compositor.audit.log</field>
<field name="arch" type="xml">
<search string="Audit Log">
<field name="user_id"/>
<field name="app_id"/>
<field name="session_id"/>
<field name="client_ip"/>
<field name="detail"/>
<filter name="crashes" string="Crashes" domain="[('event_type', '=', 'app_crash')]"/>
<filter name="launches" string="Launches" domain="[('event_type', '=', 'app_launch')]"/>
<filter name="terminations" string="Terminations" domain="[('event_type', 'in', ['app_terminate', 'app_force_kill'])]"/>
<filter name="auth" string="Auth Events" domain="[('event_type', 'like', 'jwt_')]"/>
<separator/>
<filter name="today" string="Today" domain="[('create_date', '>=', (context_today() - datetime.timedelta(days=1)).strftime('%Y-%m-%d 23:59:59'))]"/>
<filter name="week" string="This Week" domain="[('create_date', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<separator/>
<filter name="group_event" string="Event Type" context="{'group_by': 'event_type'}"/>
<filter name="group_user" string="User" context="{'group_by': 'user_id'}"/>
<filter name="group_app" string="App" context="{'group_by': 'app_id'}"/>
<filter name="group_day" string="Day" context="{'group_by': 'create_date:day'}"/>
</search>
</field>
</record>
<!-- ─── Action ────────────────────────────────────────────────── -->
<record id="action_compositor_audit_log" model="ir.actions.act_window">
<field name="name">Audit Log</field>
<field name="res_model">compositor.audit.log</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_compositor_audit_log_search"/>
</record>
</data>
</odoo>
+258
View File
@@ -0,0 +1,258 @@
# -*- coding: utf-8 -*-
"""``compositor.session`` — Per-browser-tab compositor session.
A session represents one active OWL shell instance in a browser tab. Each
session has its own:
- Wayland display (``$XDG_RUNTIME_DIR/wayland-N``) on the proxy host
- Forked Python proxy worker process
- JWT (15-min, rotated by the OWL shell)
- Set of launched app instances (``compositor.session.app``)
"""
import uuid
from odoo import api, fields, models
class CompositorSession(models.Model):
_name = "compositor.session"
_description = "1B Space Compositor Session"
_inherit = ["mail.thread"]
_order = "started_at desc"
# ─── Identity ────────────────────────────────────────────────────────────
compositor_session_id = fields.Char(
string="Compositor Session UUID",
required=True,
copy=False,
default=lambda self: str(uuid.uuid4()),
tracking=True,
help="The UUID generated by the OWL shell and embedded in the JWT. "
"The proxy uses this to scope Wayland display + websocket connections.",
)
# ─── Relations ──────────────────────────────────────────────────────────
user_id = fields.Many2one(
"res.users",
string="User",
required=True,
default=lambda self: self.env.user,
tracking=True,
)
company_id = fields.Many2one(
"res.company",
string="Company",
default=lambda self: self.env.company,
)
app_instance_ids = fields.One2many(
"compositor.session.app",
"session_id",
string="App Instances",
)
app_count = fields.Integer(
string="Apps Launched",
compute="_compute_app_count",
)
running_app_count = fields.Integer(
string="Running Now",
compute="_compute_app_count",
)
# ─── State ──────────────────────────────────────────────────────────────
state = fields.Selection(
[
("pending", "Pending"),
("active", "Active"),
("disconnected", "Disconnected"),
("closed", "Closed"),
],
string="State",
default="pending",
required=True,
tracking=True,
)
# ─── Proxy info ──────────────────────────────────────────────────────────
proxy_url = fields.Char(
string="Proxy URL",
help="WebSocket URL of the Python compositor proxy that this session connects to.",
)
proxy_pid = fields.Integer(
string="Proxy Worker PID",
help="PID of the forked Python proxy worker process on the proxy host.",
)
wayland_display = fields.Char(
string="Wayland Display",
help="The WAYLAND_DISPLAY value used by this session's apps, e.g. 'wayland-3'.",
)
# ─── Lifecycle ──────────────────────────────────────────────────────────
started_at = fields.Datetime(
string="Started At",
default=fields.Datetime.now,
)
last_activity = fields.Datetime(
string="Last Activity",
tracking=True,
)
ended_at = fields.Datetime(
string="Ended At",
)
duration_seconds = fields.Integer(
string="Duration (s)",
compute="_compute_duration_seconds",
)
# ─── JWT ────────────────────────────────────────────────────────────────
last_jwt_issued_at = fields.Datetime(
string="Last JWT Issued",
)
last_jwt_expires_at = fields.Datetime(
string="Last JWT Expires",
)
@api.depends("app_instance_ids", "app_instance_ids.state")
def _compute_app_count(self):
for record in self:
record.app_count = len(record.app_instance_ids)
record.running_app_count = sum(
1 for inst in record.app_instance_ids if inst.state == "running"
)
@api.depends("started_at", "ended_at")
def _compute_duration_seconds(self):
for record in self:
end = record.ended_at or fields.Datetime.now()
if record.started_at:
record.duration_seconds = int((end - record.started_at).total_seconds())
else:
record.duration_seconds = 0
# ─── Public methods ────────────────────────────────────────────────────
def action_close(self):
"""Force-close the session and all running apps."""
for session in self:
# In a real impl, this calls the proxy via HTTP to terminate
# For now, mark as closed
session.app_instance_ids.write({"state": "terminated", "ended_at": fields.Datetime.now()})
session.write({
"state": "closed",
"ended_at": fields.Datetime.now(),
})
def action_reconnect(self):
"""Mark the session as active again (e.g. after a browser refresh)."""
for session in self:
session.state = "active"
session.last_activity = fields.Datetime.now()
def action_view_app_instances(self):
"""Open the app instances view filtered to this session."""
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": "App Instances",
"res_model": "compositor.session.app",
"view_mode": "list,form",
"domain": [("session_id", "=", self.id)],
"context": {"default_session_id": self.id},
}
def touch(self):
"""Update last_activity timestamp. Called by the OWL shell periodically."""
self.write({"last_activity": fields.Datetime.now()})
class CompositorSessionApp(models.Model):
_name = "compositor.session.app"
_description = "App Instance Running in a Compositor Session"
_order = "started_at desc"
session_id = fields.Many2one(
"compositor.session",
string="Session",
required=True,
ondelete="cascade",
)
app_id = fields.Many2one(
"compositor.app",
string="Application",
required=True,
ondelete="restrict",
)
# ─── State ──────────────────────────────────────────────────────────────
state = fields.Selection(
[
("launching", "Launching"),
("running", "Running"),
("terminated", "Terminated"),
("crashed", "Crashed"),
],
string="State",
default="launching",
required=True,
)
# ─── Process info (from proxy) ──────────────────────────────────────────
pid = fields.Integer(string="PID", help="Process ID on the proxy host.")
signal_url = fields.Char(string="Signal WebSocket URL")
compositor_surface_id = fields.Char(
string="Surface ID",
help="Internal ID used by the OWL shell to identify the wl_surface.",
)
# ─── Window state ───────────────────────────────────────────────────────
title = fields.Char(string="Window Title", default="(no title)")
app_id_str = fields.Char(
string="App ID String",
help="The xdg_toplevel.app_id sent by the app (e.g. 'org.gnome.Calculator').",
)
width = fields.Integer(string="Width", default=800)
height = fields.Integer(string="Height", default=600)
is_maximized = fields.Boolean(string="Maximized", default=False)
is_fullscreen = fields.Boolean(string="Fullscreen", default=False)
is_minimized = fields.Boolean(string="Minimized", default=False)
is_focused = fields.Boolean(string="Focused", default=False)
# ─── Lifecycle ──────────────────────────────────────────────────────────
started_at = fields.Datetime(
string="Started At",
default=fields.Datetime.now,
)
ended_at = fields.Datetime(
string="Ended At",
)
exit_code = fields.Integer(string="Exit Code")
signal_received = fields.Char(string="Signal Received")
# ─── Computed ───────────────────────────────────────────────────────────
duration_seconds = fields.Integer(
string="Duration (s)",
compute="_compute_duration_seconds",
)
@api.depends("started_at", "ended_at")
def _compute_duration_seconds(self):
for record in self:
end = record.ended_at or fields.Datetime.now()
if record.started_at:
record.duration_seconds = int((end - record.started_at).total_seconds())
else:
record.duration_seconds = 0
def action_terminate(self):
"""Send a SIGTERM to the sandboxed app via the proxy."""
for inst in self:
# In a real impl, calls the proxy via HTTP
inst.write({
"state": "terminated",
"ended_at": fields.Datetime.now(),
})
def action_force_kill(self):
"""Send a SIGKILL to the sandboxed app via the proxy."""
for inst in self:
inst.write({
"state": "terminated",
"ended_at": fields.Datetime.now(),
"signal_received": "SIGKILL",
})
+140
View File
@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- ─── Session List View ─────────────────────────────────────── -->
<record id="view_compositor_session_list" model="ir.ui.view">
<field name="name">compositor.session.list</field>
<field name="model">compositor.session</field>
<field name="arch" type="xml">
<list string="Compositor Sessions">
<field name="compositor_session_id"/>
<field name="user_id"/>
<field name="state" widget="badge"
decoration-success="state == 'active'"
decoration-warning="state == 'disconnected'"
decoration-danger="state == 'closed'"
decoration-info="state == 'pending'"/>
<field name="running_app_count" string="Apps"/>
<field name="started_at"/>
<field name="last_activity"/>
<field name="duration_seconds" widget="float_time" string="Duration"/>
</list>
</field>
</record>
<!-- ─── Session Form View ─────────────────────────────────────── -->
<record id="view_compositor_session_form" model="ir.ui.view">
<field name="name">compositor.session.form</field>
<field name="model">compositor.session</field>
<field name="arch" type="xml">
<form string="Compositor Session">
<header>
<button name="action_close" string="Close Session" type="object"
class="btn-danger"
confirm="This will terminate all running apps. Continue?"
invisible="state == 'closed'"/>
<button name="action_reconnect" string="Mark Reconnected" type="object"
class="btn-secondary"
invisible="state not in ['disconnected', 'closed']"/>
<field name="state" widget="statusbar"
statusbar_visible="pending,active,disconnected,closed"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_app_instances" type="object"
class="oe_stat_button" icon="fa-desktop">
<field name="running_app_count" widget="statinfo"
string="Running Apps"/>
</button>
<button name="action_view_app_instances" type="object"
class="oe_stat_button" icon="fa-list">
<field name="app_count" widget="statinfo" string="Total Apps"/>
</button>
</div>
<group>
<group>
<field name="compositor_session_id" readonly="1"/>
<field name="user_id" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="proxy_url" readonly="1"/>
<field name="wayland_display" readonly="1"/>
<field name="proxy_pid" readonly="1"/>
</group>
</group>
<group>
<group string="Timing">
<field name="started_at" readonly="1"/>
<field name="last_activity" readonly="1"/>
<field name="ended_at" readonly="1"/>
<field name="duration_seconds" widget="float_time" readonly="1"/>
</group>
<group string="JWT">
<field name="last_jwt_issued_at" readonly="1"/>
<field name="last_jwt_expires_at" readonly="1"/>
</group>
</group>
<notebook>
<page string="App Instances" name="app_instances">
<field name="app_instance_ids" context="{'default_session_id': id}">
<list>
<field name="app_id"/>
<field name="title"/>
<field name="state" widget="badge"
decoration-success="state == 'running'"
decoration-info="state == 'launching'"
decoration-danger="state == 'crashed'"
decoration-muted="state == 'terminated'"/>
<field name="pid"/>
<field name="started_at"/>
<field name="ended_at"/>
<button name="action_terminate" type="object" string="Terminate"
class="btn-danger btn-sm" icon="fa-stop"
invisible="state not in ['launching', 'running']"/>
<button name="action_force_kill" type="object" string="Force Kill"
class="btn-danger btn-sm" icon="fa-times"
invisible="state not in ['launching', 'running']"/>
</list>
</field>
</page>
<page string="Audit Log" name="audit_log">
<field name="message_ids" context="{'default_session_id': id}"/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- ─── Session Search View ───────────────────────────────────── -->
<record id="view_compositor_session_search" model="ir.ui.view">
<field name="name">compositor.session.search</field>
<field name="model">compositor.session</field>
<field name="arch" type="xml">
<search string="Compositor Sessions">
<field name="compositor_session_id"/>
<field name="user_id"/>
<filter name="active" string="Active" domain="[('state', '=', 'active')]"/>
<filter name="disconnected" string="Disconnected" domain="[('state', '=', 'disconnected')]"/>
<filter name="my_sessions" string="My Sessions" domain="[('user_id', '=', uid)]"/>
<separator/>
<filter name="group_user" string="User" context="{'group_by': 'user_id'}"/>
<filter name="group_state" string="State" context="{'group_by': 'state'}"/>
</search>
</field>
</record>
<!-- ─── Action ────────────────────────────────────────────────── -->
<record id="action_compositor_session" model="ir.actions.act_window">
<field name="name">Sessions</field>
<field name="res_model">compositor.session</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_compositor_session_search"/>
</record>
</data>
</odoo>
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- ─── Module Category (still exists in Odoo 20) ─────────────── -->
<record id="module_category_compositor" model="ir.module.category">
<field name="name">1B OS Compositor</field>
<field name="description">Wayland-to-HTML5 compositor for the Odoo web client.</field>
<field name="sequence">20</field>
</record>
<!-- ─── Privileges (Odoo 20's replacement for group categories) ──── -->
<record id="compositor_privilege_user" model="res.groups.privilege">
<field name="name">1B OS Compositor User</field>
<field name="description">Can launch apps from the launcher and see their own sessions.</field>
<field name="category_id" ref="module_category_compositor"/>
<field name="sequence">10</field>
</record>
<record id="compositor_privilege_manager" model="res.groups.privilege">
<field name="name">1B OS Compositor Manager</field>
<field name="description">Can configure apps, view all sessions, view audit log.</field>
<field name="category_id" ref="module_category_compositor"/>
<field name="sequence">20</field>
</record>
<!-- ─── Groups ───────────────────────────────────────────────────── -->
<record id="compositor_group_user" model="res.groups">
<field name="name">Compositor / User</field>
<field name="privilege_id" ref="compositor_privilege_user"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="compositor_group_manager" model="res.groups">
<field name="name">Compositor / Manager</field>
<field name="privilege_id" ref="compositor_privilege_manager"/>
<field name="implied_ids" eval="[(4, ref('compositor_group_user'))]"/>
<field name="user_ids" eval="[(4, ref('base.user_admin'))]"/>
</record>
</data>
</odoo>
+9
View File
@@ -0,0 +1,9 @@
id,name,model_id:id,group_id:id,operation,domain
access_compositor_app_manager,compositor.app manager,model_compositor_app,one_os_compositor.compositor_group_manager,crud,
access_compositor_session_manager,compositor.session manager,model_compositor_session,one_os_compositor.compositor_group_manager,crud,
access_compositor_session_app_manager,compositor.session.app manager,model_compositor_session_app,one_os_compositor.compositor_group_manager,crud,
access_compositor_audit_log_manager,compositor.audit.log manager,model_compositor_audit_log,one_os_compositor.compositor_group_manager,crud,
access_compositor_app_user_read,compositor.app user read active only,model_compositor_app,one_os_compositor.compositor_group_user,r,"[(""active"", ""="", True)]"
access_compositor_session_user,compositor.session user own,model_compositor_session,one_os_compositor.compositor_group_user,crud,"[(""user_id"", ""="", user.id)]"
access_compositor_session_app_user,compositor.session.app user own,model_compositor_session_app,one_os_compositor.compositor_group_user,crud,"[(""session_id.user_id"", ""="", user.id)]"
access_compositor_audit_log_user_read,compositor.audit.log user read own,model_compositor_audit_log,one_os_compositor.compositor_group_user,r,"[(""user_id"", ""="", user.id)]"
1 id name model_id:id group_id:id operation domain
2 access_compositor_app_manager compositor.app manager model_compositor_app one_os_compositor.compositor_group_manager crud
3 access_compositor_session_manager compositor.session manager model_compositor_session one_os_compositor.compositor_group_manager crud
4 access_compositor_session_app_manager compositor.session.app manager model_compositor_session_app one_os_compositor.compositor_group_manager crud
5 access_compositor_audit_log_manager compositor.audit.log manager model_compositor_audit_log one_os_compositor.compositor_group_manager crud
6 access_compositor_app_user_read compositor.app user read active only model_compositor_app one_os_compositor.compositor_group_user r [("active", "=", True)]
7 access_compositor_session_user compositor.session user own model_compositor_session one_os_compositor.compositor_group_user crud [("user_id", "=", user.id)]
8 access_compositor_session_app_user compositor.session.app user own model_compositor_session_app one_os_compositor.compositor_group_user crud [("session_id.user_id", "=", user.id)]
9 access_compositor_audit_log_user_read compositor.audit.log user read own model_compositor_audit_log one_os_compositor.compositor_group_user r [("user_id", "=", user.id)]
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="compositor_module_desc" name="1B Space Compositor Core">
<img class="oe_image" src="/one_os_compositor/static/img/icon.png"/>
<h2>1B Space Compositor Core</h2>
<p>Wayland-to-HTML5 compositor foundation — OWL library + backend models
for running real Linux apps inside Odoo natively.</p>
<p>Author: <strong>1bspace.com</strong> · Parent: <strong>1B Global</strong></p>
</template>
</data>
</odoo>
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

@@ -0,0 +1,60 @@
/**
* 1B Space Compositor — Reusable Canvas Component
* ===============================================
*
* This is the OWL component other addons use to embed a Wayland surface
* inside the Odoo web client.
*
* Usage:
*
* <CompositorCanvas surface.bind="surface" />
*
* Or as a target for attachTo():
*
* const surface = await compositor.launchApp("gnome-calculator");
* surface.attachTo(this.canvasRef.el);
*/
import { Component, useRef, onMounted, onWillUnmount, useEffect } from "@odoo/owl";
export class CompositorCanvas extends Component {
setup() {
this.canvasRef = useRef("canvas");
this.compositor = this.env.services.compositor;
onMounted(() => this._attach());
onWillUnmount(() => this._detach());
// Re-attach if the surface prop changes
useEffect(
() => {
this._detach();
this._attach();
},
() => [this.props.surface]
);
}
_attach() {
const surface = this.props.surface;
if (surface && this.canvasRef.el) {
surface.attachTo(this.canvasRef.el);
}
}
_detach() {
const surface = this.props.surface;
if (surface) {
surface.detach();
}
}
}
CompositorCanvas.template = "one_os_compositor.CompositorCanvas";
CompositorCanvas.props = {
surface: { type: Object, optional: true },
className: { type: String, optional: true },
style: { type: String, optional: true },
};
export default CompositorCanvas;
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="one_os_compositor.CompositorCanvas">
<div class="o_compositor_canvas_wrapper" t-att-class="props.className" t-att-style="props.style">
<canvas t-ref="canvas" class="o_compositor_canvas" tabindex="1"/>
</div>
</t>
</templates>
+30
View File
@@ -0,0 +1,30 @@
// 1B Space Compositor styles
// ============================
.o_compositor_canvas_wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
.o_compositor_canvas {
display: block;
width: 100%;
height: 100%;
background: #000;
outline: none;
&:focus {
outline: 2px solid #6f42c1;
}
}
}
// Compositor menu icon
.o_compositor_menu_icon {
&:before {
content: "\f108";
font-family: "FontAwesome";
margin-right: 8px;
}
}
+372
View File
@@ -0,0 +1,372 @@
/**
* 1B Space Compositor Service
* ===========================
*
* The single integration point between Odoo and the Wayland compositor.
*
* Architecture
* ------------
* The browser-side compositor (custom TypeScript library, to be built
* clean-room per the MIT analysis) connects to the Python compositor proxy
* via WebSocket + KCP-over-WebSocket. This service wraps that connection
* so other Odoo addons can use it without knowing about Wayland.
*
* Lifecycle
* ---------
* 1. ``init()`` — called when the service is first used. Fetches a JWT
* from the Odoo backend via ``rpc("/web/compositor/token")``.
* 2. ``connect()`` — opens a WebSocket to the proxy URL returned with the JWT.
* 3. ``launchApp(appId)`` — calls ``rpc("/web/compositor/launch")`` which
* tells Odoo to tell the proxy to spawn a sandboxed app.
* 4. The proxy returns a ``signal_url`` which the browser opens as a
* separate WebSocket for control messages.
* 5. ``attachSurface(surfaceId, canvasEl)`` — binds a Wayland surface to
* an HTML5 canvas element for rendering.
*
* Status: STUB
* ------------
* This is the public API stub. The actual Wayland wire protocol,
* WebGL2 rendering, etc. is implemented in the ``compositor_lib`` folder
* (to be built clean-room).
*/
import { useService } from "@web/core/utils/hooks";
export class CompositorService {
constructor() {
this._jwt = null;
this._proxyUrl = null;
this._compositorSessionId = null;
this._sessionDbId = null;
this._jwtExpiresAt = null;
this._connected = false;
this._controlSocket = null;
this._surfaces = new Map();
this._eventListeners = new Map();
this._refreshTimer = null;
this._rpc = null; // Set on init
}
// ──────────────────────────────────────────────────────────────────
// Lifecycle
// ──────────────────────────────────────────────────────────────────
/**
* Initialize the service. Called automatically by OWL when first used.
*
* @param {Object} env — OWL environment (provides services)
* @param {Object} services — { rpc }
*/
start(env, services) {
this._env = env;
this._rpc = services.rpc;
// Auto-connect on first use
return this._ensureToken();
}
/**
* Ensure we have a valid JWT. Refreshes if expired.
* @private
*/
async _ensureToken() {
if (this._jwt && this._jwtExpiresAt && Date.now() < this._jwtExpiresAt.getTime() - 60000) {
return; // Still valid (with 1-min safety margin)
}
const result = await this._rpc("/web/compositor/token");
this._jwt = result.jwt;
this._proxyUrl = result.proxy_url;
this._compositorSessionId = result.compositor_session_id;
this._sessionDbId = result.session_db_id;
this._jwtExpiresAt = new Date(result.expires_at);
// Schedule refresh 1 minute before expiry
if (this._refreshTimer) clearTimeout(this._refreshTimer);
const refreshIn = this._jwtExpiresAt.getTime() - Date.now() - 60000;
this._refreshTimer = setTimeout(() => this._ensureToken(), Math.max(refreshIn, 5000));
}
// ──────────────────────────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────────────────────────
/**
* List apps the current user is allowed to launch.
* @returns {Promise<Array<{app_id, name, description, icon, color, running_session_count}>>}
*/
async listApps() {
const result = await this._rpc("/web/compositor/apps");
return result.apps;
}
/**
* Launch an app.
*
* Tells Odoo backend → tells Python proxy → spawns sandboxed app.
* Returns a SurfaceHandle that can be attached to a canvas.
*
* @param {string} appId — e.g. "gnome-calculator"
* @returns {Promise<SurfaceHandle>}
*/
async launchApp(appId) {
await this._ensureToken();
const result = await this._rpc("/web/compositor/launch", { app_id: appId });
if (result.stub) {
// Proxy not yet connected — return a stub surface for dev
return this._createStubSurface(result.compositor_surface_id, appId);
}
// Open the signal WebSocket
await this._openControlSocket(result.signal_url);
return this._createSurface(result.compositor_surface_id, appId, result.app_instance_db_id);
}
/**
* Terminate an app.
*
* @param {SurfaceHandle} surface — the surface returned by launchApp
* @param {boolean} force — if True, send SIGKILL instead of SIGTERM
*/
async terminateApp(surface, force = false) {
if (!surface || !surface.appInstanceDbId) return;
await this._rpc("/web/compositor/terminate", {
app_instance_db_id: surface.appInstanceDbId,
force,
});
this._surfaces.delete(surface.surfaceId);
this._emit("surfaceDestroyed", surface);
}
/**
* Subscribe to compositor events.
*
* @param {string} eventName — one of: surfaceCreated, surfaceDestroyed,
* surfaceTitleUpdated, surfaceActivationUpdated, surfaceStateChanged,
* clientCreated, clientDestroyed, clientUnresponsiveUpdated
* @param {Function} callback
* @returns {Function} unsubscribe function
*/
on(eventName, callback) {
if (!this._eventListeners.has(eventName)) {
this._eventListeners.set(eventName, new Set());
}
this._eventListeners.get(eventName).add(callback);
return () => this._eventListeners.get(eventName).delete(callback);
}
/**
* Get a list of currently active surfaces.
* @returns {Array<SurfaceHandle>}
*/
getActiveSurfaces() {
return Array.from(this._surfaces.values());
}
/**
* Focus a surface (bring to front).
* @param {SurfaceHandle} surface
*/
async focusSurface(surface) {
if (!surface) return;
// TODO: send wl_surface.activate via the control socket
// For now, just emit a local event
this._emit("surfaceActivationUpdated", { surface, active: true });
}
// ──────────────────────────────────────────────────────────────────
// Internal: WebSocket control
// ──────────────────────────────────────────────────────────────────
async _openControlSocket(signalUrl) {
if (this._controlSocket && this._controlSocket.readyState === WebSocket.OPEN) {
return;
}
// The signal URL includes a `key` query param for auth. We also
// pass the JWT as a Sec-WebSocket-Protocol subprotocol.
const ws = new WebSocket(signalUrl, ["compositor", `bearer.${this._jwt}`]);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
this._connected = true;
this._emit("connected", {});
};
ws.onclose = (ev) => {
this._connected = false;
this._emit("disconnected", { code: ev.code, reason: ev.reason });
// Auto-reconnect with exponential backoff (3s, 6s, 12s, 24s, 30s cap)
if (!this._reconnectTimer) {
this._reconnectAttempts = 0;
this._scheduleReconnect(signalUrl);
}
};
ws.onmessage = (ev) => this._handleControlMessage(ev.data);
ws.onerror = (err) => {
console.error("[one_os_compositor] control socket error:", err);
};
this._controlSocket = ws;
}
_scheduleReconnect(signalUrl) {
const delays = [3000, 6000, 12000, 24000, 30000];
const delay = delays[Math.min(this._reconnectAttempts, delays.length - 1)];
this._reconnectAttempts++;
this._reconnectTimer = setTimeout(async () => {
this._reconnectTimer = null;
try {
await this._ensureToken();
await this._openControlSocket(signalUrl);
this._reconnectAttempts = 0;
} catch (err) {
console.error("[one_os_compositor] reconnect failed:", err);
this._scheduleReconnect(signalUrl);
}
}, delay);
}
_handleControlMessage(data) {
// Control messages are JSON; surface frames are binary on a separate channel.
try {
const msg = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data));
switch (msg.type) {
case "CONNECT_CHANNEL":
// Proxy is asking us to open a new data channel WebSocket
this._openDataChannel(msg.url, msg.desc);
break;
case "APP_TERMINATED":
this._handleAppTerminated(msg);
break;
case "PING":
// Heartbeat — respond with PONG
if (this._controlSocket.readyState === WebSocket.OPEN) {
this._controlSocket.send(JSON.stringify({ type: "PONG" }));
}
break;
default:
console.debug("[one_os_compositor] unhandled control message:", msg);
}
} catch (e) {
console.error("[one_os_compositor] failed to parse control message:", e);
}
}
_openDataChannel(url, desc) {
// TODO: open the per-surface data WebSocket for encoded frame streaming
// The actual KCP-over-WebSocket layer is implemented in compositor_lib/
console.debug("[one_os_compositor] opening data channel:", url, desc);
}
_handleAppTerminated(msg) {
const surface = this._surfaces.get(msg.compositor_surface_id);
if (surface) {
this._surfaces.delete(msg.compositor_surface_id);
this._emit("surfaceDestroyed", surface);
}
}
// ──────────────────────────────────────────────────────────────────
// Internal: surface management
// ──────────────────────────────────────────────────────────────────
_createSurface(surfaceId, appId, appInstanceDbId) {
const surface = {
surfaceId,
appId,
appInstanceDbId,
title: "(no title)",
appIdStr: appId,
width: 800,
height: 600,
isMaximized: false,
isFullscreen: false,
isMinimized: false,
isActive: false,
// Attach this surface to a canvas element for rendering
attachTo: (canvasEl) => this._attachSurfaceToCanvas(surface, canvasEl),
// Detach from current canvas
detach: () => this._detachSurfaceFromCanvas(surface),
// Update window state
setState: (state) => this._setSurfaceState(surface, state),
};
this._surfaces.set(surfaceId, surface);
this._emit("surfaceCreated", surface);
return surface;
}
_createStubSurface(surfaceId, appId) {
// For dev — when the proxy isn't connected yet, return a surface
// that renders a placeholder so the OWL shell can be developed.
const surface = this._createSurface(surfaceId, appId, null);
surface.stub = true;
return surface;
}
_attachSurfaceToCanvas(surface, canvasEl) {
// TODO: bind this surface to the canvas via the compositor_lib renderer
// For now, just store a reference and emit an event
surface.canvasEl = canvasEl;
if (surface.stub) {
this._renderStub(canvasEl, surface);
}
this._emit("surfaceAttached", surface);
}
_detachSurfaceFromCanvas(surface) {
if (surface.canvasEl) {
const ctx = surface.canvasEl.getContext("2d");
ctx.clearRect(0, 0, surface.canvasEl.width, surface.canvasEl.height);
surface.canvasEl = null;
}
this._emit("surfaceDetached", surface);
}
_renderStub(canvasEl, surface) {
// Render a placeholder so the OWL shell can develop without the proxy
const ctx = canvasEl.getContext("2d");
canvasEl.width = surface.width;
canvasEl.height = surface.height;
ctx.fillStyle = "#1a1a2e";
ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
ctx.fillStyle = "#e94560";
ctx.font = "bold 24px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${surface.appId}`, canvasEl.width / 2, canvasEl.height / 2 - 20);
ctx.fillStyle = "#a0a0a0";
ctx.font = "14px monospace";
ctx.fillText("(stub — install one_os_compositor_proxy to launch real app)", canvasEl.width / 2, canvasEl.height / 2 + 20);
ctx.fillText(`surface id: ${surface.surfaceId}`, canvasEl.width / 2, canvasEl.height / 2 + 50);
}
_setSurfaceState(surface, state) {
Object.assign(surface, state);
this._emit("surfaceStateChanged", surface);
}
// ──────────────────────────────────────────────────────────────────
// Internal: event emitter
// ──────────────────────────────────────────────────────────────────
_emit(eventName, payload) {
const listeners = this._eventListeners.get(eventName);
if (listeners) {
listeners.forEach(cb => {
try {
cb(payload);
} catch (e) {
console.error(`[one_os_compositor] ${eventName} listener error:`, e);
}
});
}
}
}
/**
* @typedef {Object} SurfaceHandle
* @property {string} surfaceId
* @property {string} appId
* @property {number|null} appInstanceDbId
* @property {string} title
* @property {string} appIdStr
* @property {number} width
* @property {number} height
* @property {boolean} isMaximized
* @property {boolean} isFullscreen
* @property {boolean} isMinimized
* @property {boolean} isActive
* @property {boolean} stub — true if the proxy isn't connected yet
* @property {HTMLCanvasElement|null} canvasEl
* @property {Function} attachTo
* @property {Function} detach
* @property {Function} setState
*/
@@ -0,0 +1,39 @@
/**
* 1B Space Compositor — OWL service registry
* ============================================
*
* This file registers the CompositorService as an Odoo OWL service.
* Other addons can use it via:
*
* class MyComponent extends Component {
* setup() {
* this.compositor = useService("compositor");
* }
* async launchApp(appId) {
* const surface = await this.compositor.launchApp(appId);
* surface.attachTo(this.canvasRef.el);
* }
* }
*
* The service handles:
* - JWT auth (issued by Odoo backend, refreshed automatically)
* - WebSocket connection to the Python compositor proxy
* - App launch / terminate / focus
* - Surface lifecycle events (created, destroyed, title-updated, etc.)
*
* License: Proprietary (1B Space internal use)
* Author: 1bspace.com
*/
import { registry } from "@web/core/registry";
import { CompositorService } from "./compositor_service";
// Register the service so it can be used via useService("compositor")
registry.category("services").add("compositor", new CompositorService());
// Also expose on window for debugging in dev mode
if (typeof window !== "undefined" && window.location.search.includes("debug")) {
window.__oneOSCompositor = { CompositorService };
}
export { CompositorService };
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""Tests for the 1B Space Compositor Core addon."""
from . import test_compositor_app
from . import test_compositor_session
from . import test_compositor_controller
+59
View File
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""Tests for compositor.app model."""
from odoo.exceptions import ValidationError, AccessError
from odoo.tests import common, tagged
@tagged("post_install", "-at_install")
class TestCompositorApp(common.TransactionCase):
def setUp(self):
super().setUp()
self.App = self.env["compositor.app"]
self.group_user = self.env.ref("base.group_user")
self.group_manager = self.env.ref("one_os_compositor.compositor_group_manager")
def test_create_app_minimal(self):
app = self.App.create({
"name": "Test App",
"app_id": "test-app",
"executable": "/bin/true",
})
self.assertEqual(app.memory_limit_mb, 1024)
self.assertEqual(app.cpu_quota_percent, 200)
self.assertEqual(app.tasks_max, 256)
self.assertTrue(app.sandbox_gpu)
self.assertFalse(app.sandbox_network)
def test_app_id_unique(self):
self.App.create({"name": "App 1", "app_id": "duplicate", "executable": "/bin/true"})
with self.assertRaises(ValidationError):
self.App.create({"name": "App 2", "app_id": "duplicate", "executable": "/bin/true"})
def test_memory_limit_too_low(self):
with self.assertRaises(ValidationError):
self.App.create({
"name": "Bad App", "app_id": "bad-app",
"executable": "/bin/true", "memory_limit_mb": 32,
})
def test_get_launch_config(self):
app = self.App.create({
"name": "Test", "app_id": "test",
"executable": "/usr/bin/test",
"args": "--foo --bar",
"env_vars": "GDK_SCALE=1\n# comment\nGTK_DEBUG=all",
"memory_limit_mb": 512,
"cpu_quota_percent": 100,
"tasks_max": 64,
"sandbox_network": True,
"sandbox_gpu": False,
})
config = app.get_launch_config()
self.assertEqual(config["app_id"], "test")
self.assertEqual(config["executable"], "/usr/bin/test")
self.assertEqual(config["args"], "--foo --bar")
self.assertEqual(config["env"], {"GDK_SCALE": "1", "GTK_DEBUG": "all"})
self.assertEqual(config["memory_limit_mb"], 512)
self.assertTrue(config["sandbox_network"])
self.assertFalse(config["sandbox_gpu"])
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""Tests for compositor HTTP controllers."""
from odoo.tests import HttpCase, tagged
@tagged("post_install", "-at_install")
class TestCompositorController(HttpCase):
def setUp(self):
super().setUp()
# Make sure a JWT secret is set
self.env["ir.config_parameter"].set_str(
"compositor.jwt_secret", "test-secret-1bspace"
)
def test_get_token(self):
"""Test that /web/compositor/token returns a valid JWT."""
self.authenticate("admin", "admin")
response = self.url_open(
"/web/compositor/token",
data="{}",
headers={"Content-Type": "application/json"},
timeout=30,
)
self.assertEqual(response.status_code, 200, response.text)
result = response.json()
self.assertIn("jwt", result["result"])
self.assertIn("proxy_url", result["result"])
self.assertIn("expires_at", result["result"])
self.assertIn("compositor_session_id", result["result"])
def test_list_apps(self):
"""Test that /web/compositor/apps returns the pre-installed apps."""
self.authenticate("admin", "admin")
response = self.url_open(
"/web/compositor/apps",
data="{}",
headers={"Content-Type": "application/json"},
timeout=30,
)
self.assertEqual(response.status_code, 200, response.text)
result = response.json()["result"]
self.assertIn("apps", result)
app_ids = [a["app_id"] for a in result["apps"]]
self.assertIn("gnome-calculator", app_ids)
self.assertIn("xterm", app_ids)
def test_launch_app_stub(self):
"""Test that launching an app returns a stub response when proxy isn't connected."""
self.authenticate("admin", "admin")
# First get a token
token_resp = self.url_open(
"/web/compositor/token",
data="{}",
headers={"Content-Type": "application/json"},
timeout=30,
).json()["result"]
# Then try to launch
response = self.url_open(
"/web/compositor/launch",
data='{"app_id": "gnome-calculator"}',
headers={"Content-Type": "application/json"},
timeout=30,
)
self.assertEqual(response.status_code, 200, response.text)
result = response.json()["result"]
# Since proxy isn't connected, we should get a stub
self.assertTrue(result.get("stub", False))
self.assertIn("compositor_surface_id", result)
+45
View File
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""Tests for compositor.session model."""
from odoo.tests import common, tagged
@tagged("post_install", "-at_install")
class TestCompositorSession(common.TransactionCase):
def setUp(self):
super().setUp()
self.Session = self.env["compositor.session"]
self.App = self.env["compositor.app"]
def test_create_session_defaults(self):
session = self.Session.create({})
self.assertEqual(session.state, "pending")
self.assertTrue(session.compositor_session_id)
self.assertEqual(session.user_id, self.env.user)
self.assertEqual(session.app_count, 0)
self.assertEqual(session.running_app_count, 0)
def test_session_app_lifecycle(self):
session = self.Session.create({})
app = self.App.create({
"name": "Test", "app_id": "test-lifecycle", "executable": "/bin/true",
})
inst = self.env["compositor.session.app"].create({
"session_id": session.id, "app_id": app.id,
})
self.assertEqual(inst.state, "launching")
inst.write({"state": "running"})
self.assertEqual(session.running_app_count, 1)
inst.action_terminate()
self.assertEqual(inst.state, "terminated")
self.assertEqual(session.running_app_count, 0)
def test_action_close(self):
session = self.Session.create({})
app = self.App.create({"name": "X", "app_id": "x", "executable": "/bin/true"})
inst = self.env["compositor.session.app"].create({
"session_id": session.id, "app_id": app.id, "state": "running",
})
session.action_close()
self.assertEqual(session.state, "closed")
self.assertEqual(inst.state, "terminated")
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- ─── Top-level menu ─────────────────────────────────────────── -->
<menuitem id="menu_compositor_root"
name="1B OS"
web_icon="one_os_compositor,static/img/icon.png"
sequence="60"/>
<!-- ─── Compositor → Open Shell ──────────────────────────────── -->
<menuitem id="menu_compositor_shell"
name="Open Compositor"
parent="menu_compositor_root"
action="action_compositor_shell"
sequence="10"/>
<!-- ─── Compositor → Apps ────────────────────────────────────── -->
<menuitem id="menu_compositor_apps"
name="Apps"
parent="menu_compositor_root"
action="action_compositor_app"
sequence="20"/>
<!-- ─── Compositor → Sessions ────────────────────────────────── -->
<menuitem id="menu_compositor_sessions"
name="Sessions"
parent="menu_compositor_root"
action="action_compositor_session"
sequence="30"/>
<!-- ─── Compositor → Audit Log ───────────────────────────────── -->
<menuitem id="menu_compositor_audit_log"
name="Audit Log"
parent="menu_compositor_root"
action="action_compositor_audit_log"
sequence="40"/>
<!-- ─── Compositor → Configuration ───────────────────────────── -->
<menuitem id="menu_compositor_config"
name="Configuration"
parent="menu_compositor_root"
groups="one_os_compositor.compositor_group_manager"
sequence="99"/>
</data>
</odoo>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Full-screen client action that opens the compositor shell.
The actual shell UI is in the sister addon `one_os_compositor_shell`.
If the shell addon isn't installed, this opens a minimal embedded
surface browser. -->
<record id="action_compositor_shell" model="ir.actions.client">
<field name="name">1B Space Compositor</field>
<field name="tag">one_os_compositor_shell</field>
<field name="target">fullscreen</field>
<field name="res_model">compositor.session</field>
</record>
</odoo>