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:
commit
93a5a0a221
32 files changed
+2497
No files matched your search
+24
@@ -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/
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Compositor HTTP controllers."""
|
||||||
|
|
||||||
|
from . import main
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Compositor backend models."""
|
||||||
|
|
||||||
|
from . import compositor_app
|
||||||
|
from . import compositor_session
|
||||||
|
from . import compositor_audit_log
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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)]"
|
||||||
|
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
@@ -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"])
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user