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)
259 lines
10 KiB
Python
259 lines
10 KiB
Python
# -*- 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",
|
|
})
|