Files
one-os-dev-wayland-html5-co…/models/compositor_session.py
T
1b-agent 93a5a0a221 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)
2026-10-08 08:48:46 +00:00

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",
})