Initial commit: one_os_compositor_shell Odoo 20 addon — full-screen desktop shell UI

Includes:
- CompositorShell OWL component (ir.actions.client, target=fullscreen)
- App launcher grid (purple 1B Space accent, hover animations)
- Taskbar showing running apps
- Window decorations (close button)
- Top bar with session UUID display
- Loading overlay + error handling
- Modern SCSS with backdrop blur, gradients, dark theme
- Depends on one_os_compositor (parent addon)

Author: 1bspace.com
Parent company: 1B Global
License: Proprietary (1B Space internal use)
This commit is contained in:
1b-agent committed 2026-10-08 08:48:49 +00:00
commit de41ebdcc3
12 files changed
+713

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Python
__pycache__/
*.pyc
*.pyo
.venv/
venv/
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
# Logs
*.log
# Build
node_modules/
dist/
build/
*.egg-info/
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
+47
View File
@@ -0,0 +1,47 @@
{
"name": "1B Space Compositor Shell",
"version": "20.0.1.0.0",
"category": "1B Global/OS",
"summary": "Full-screen desktop shell UI for the 1B Space Compositor",
"description": """
1B Space Compositor Shell
=========================
This addon provides the full-screen desktop shell UI for the 1B Space
Compositor. It is what the user sees when they click "Open Compositor"
in the Odoo menu.
Features:
- App launcher grid (lists all apps the user is allowed to launch)
- Per-output canvas (one <canvas> per OWL shell instance)
- Taskbar showing running apps
- Window decorations (close, maximize buttons)
- Keyboard layout picker (RMLVO)
This addon DEPENDS on `one_os_compositor` (the core OWL library + backend models).
Author & License
----------------
- Author: 1bspace.com
- Company: 1B Global
- License: Proprietary (1B Space internal use)
""",
"author": "1bspace.com",
"website": "https://1bspace.com",
"maintainer": "1B Global <dev@1bspace.com>",
"license": "Other proprietary",
"depends": ["one_os_compositor"],
"data": [
"views/compositor_shell_views.xml",
],
"assets": {
"web.assets_backend": [
"one_os_compositor_shell/static/src/components/**/*.js",
"one_os_compositor_shell/static/src/components/**/*.xml",
"one_os_compositor_shell/static/src/scss/*.scss",
],
},
"installable": True,
"application": True,
"auto_install": False,
}
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""Compositor Shell controllers."""
from . import main
+7
View File
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
"""Compositor Shell — minimal placeholder controller.
The actual shell is a full OWL component registered via `ir.actions.client`
in `one_os_compositor` (the parent addon). This module is reserved for any
shell-specific HTTP routes we might add later (e.g. asset bundle routes).
"""
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Compositor Shell — models (none yet)."""
@@ -0,0 +1,171 @@
/**
* 1B Space Compositor Shell — main OWL component
* ===============================================
*
* This is the full-screen desktop shell UI. It's mounted by Odoo's
* `ir.actions.client` mechanism (tag = "one_os_compositor_shell",
* target = "fullscreen").
*
* The shell:
* 1. Fetches a JWT from the Odoo backend (via rpc("/web/compositor/token"))
* 2. Lists apps the user can launch (via rpc("/web/compositor/apps"))
* 3. Renders an app launcher grid
* 4. When the user clicks an app:
* - Calls rpc("/web/compositor/launch")
* - Receives a SurfaceHandle from the compositor service
* - Attaches the surface to a canvas
* - Renders a window decoration around it (close, maximize)
*
* License: Proprietary (1B Space internal use)
* Author: 1bspace.com
*/
import { Component, useState, useRef, onMounted, onWillUnmount, useEffect } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
const STATE = {
IDLE: "idle",
LOADING_APPS: "loading_apps",
LAUNCHING_APP: "launching_app",
ERROR: "error",
};
export class CompositorShell extends Component {
setup() {
this.rpc = useService("rpc");
this.notification = useService("notification");
this.compositor = useService("compositor");
this.ui = useService("ui");
// Reactive state — using proxy() per Odoo 20 OWL 3.x
this.state = useState({
phase: STATE.IDLE,
apps: [],
surfaces: [], // active SurfaceHandles
activeSurfaceId: null,
jwt: null,
sessionUuid: null,
error: null,
showLauncher: true,
});
this.canvasRef = useRef("canvas");
this.launcherRef = useRef("launcher");
onMounted(() => this._initialize());
onWillUnmount(() => this._cleanup());
// Subscribe to compositor events
this._cleanupCallbacks = [];
this._cleanupCallbacks.push(
this.compositor.on("surfaceCreated", (s) => this._onSurfaceCreated(s)),
this.compositor.on("surfaceDestroyed", (s) => this._onSurfaceDestroyed(s)),
this.compositor.on("surfaceTitleUpdated", (s) => this._onSurfaceUpdated(s)),
this.compositor.on("surfaceActivationUpdated", ({ surface, active }) =>
this._onSurfaceActivationChanged(surface, active)
),
);
}
// ──────────────────────────────────────────────────────────────────
// Lifecycle
// ──────────────────────────────────────────────────────────────────
async _initialize() {
try {
this.state.phase = STATE.LOADING_APPS;
// Get JWT (compositor service handles this internally, but we
// also stash it here for the UI)
const tokenResult = await this.rpc("/web/compositor/token", {});
this.state.jwt = tokenResult.jwt;
this.state.sessionUuid = tokenResult.compositor_session_id;
// Load apps
const appsResult = await this.rpc("/web/compositor/apps", {});
this.state.apps = appsResult.apps;
this.state.phase = STATE.IDLE;
} catch (e) {
console.error("[one_os_compositor_shell] init failed:", e);
this.state.error = e.message || String(e);
this.state.phase = STATE.ERROR;
}
}
_cleanup() {
for (const cb of this._cleanupCallbacks) cb && cb();
this._cleanupCallbacks = [];
}
// ──────────────────────────────────────────────────────────────────
// User actions
// ──────────────────────────────────────────────────────────────────
async onClickApp(appId) {
if (this.state.phase === STATE.LAUNCHING_APP) return;
this.state.phase = STATE.LAUNCHING_APP;
try {
const surface = await this.compositor.launchApp(appId);
this.state.surfaces.push(surface);
this.state.activeSurfaceId = surface.surfaceId;
this.state.showLauncher = false;
this.notification.add(`Launched ${surface.appId}`, { type: "success" });
} catch (e) {
console.error("[one_os_compositor_shell] launch failed:", e);
this.notification.add(`Launch failed: ${e.message || e}`, { type: "danger" });
} finally {
this.state.phase = STATE.IDLE;
}
}
onClickTaskbarItem(surface) {
this.state.activeSurfaceId = surface.surfaceId;
this.compositor.focusSurface(surface);
}
onClickCloseSurface(surface) {
this.compositor.terminateApp(surface);
}
onClickToggleLauncher() {
this.state.showLauncher = !this.state.showLauncher;
}
// ──────────────────────────────────────────────────────────────────
// Compositor event handlers
// ──────────────────────────────────────────────────────────────────
_onSurfaceCreated(surface) {
if (!this.state.surfaces.find(s => s.surfaceId === surface.surfaceId)) {
this.state.surfaces.push(surface);
}
}
_onSurfaceDestroyed(surface) {
const idx = this.state.surfaces.findIndex(s => s.surfaceId === surface.surfaceId);
if (idx >= 0) {
this.state.surfaces.splice(idx, 1);
}
if (this.state.activeSurfaceId === surface.surfaceId) {
this.state.activeSurfaceId = this.state.surfaces[0]?.surfaceId || null;
}
if (this.state.surfaces.length === 0) {
this.state.showLauncher = true;
}
}
_onSurfaceUpdated(surface) {
// Force re-render via reactive state
const idx = this.state.surfaces.findIndex(s => s.surfaceId === surface.surfaceId);
if (idx >= 0) {
this.state.surfaces[idx] = { ...surface };
}
}
_onSurfaceActivationChanged(surface, active) {
if (active) {
this.state.activeSurfaceId = surface.surfaceId;
}
}
}
CompositorShell.template = "one_os_compositor_shell.CompositorShell";
CompositorShell.props = {};
export default CompositorShell;
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="one_os_compositor_shell.CompositorShell">
<div class="o_compositor_shell" t-att-class="{ 'is-loading': state.phase === 'loading' or state.phase === 'launching' }">
<!-- ─── Top bar (always visible) ─────────────────────────── -->
<header class="o_compositor_topbar">
<div class="o_topbar_brand">
<img src="/one_os_compositor/static/img/icon.png" alt="1B Space"/>
<span>1B Space Compositor</span>
</div>
<div class="o_topbar_session" t-if="state.sessionUuid">
<span class="o_session_label">Session:</span>
<code t-esc="state.sessionUuid.slice(0, 8) + '...'"/>
</div>
<div class="o_topbar_actions">
<button t-on-click="onClickToggleLauncher"
class="btn btn-sm btn-light o_topbar_btn"
t-att-class="{ 'active': state.showLauncher }">
<i class="fa fa-th"/> Launcher
</button>
</div>
</header>
<!-- ─── Main content area ─────────────────────────────────── -->
<div class="o_compositor_main">
<!-- App launcher grid -->
<div t-ref="launcher" class="o_compositor_launcher"
t-if="state.showLauncher">
<div class="o_launcher_grid">
<t t-foreach="state.apps" t-as="app" t-key="app.app_id">
<button t-on-click="onClickApp(app.app_id)"
class="o_launcher_tile"
t-att-style="'--accent:' + (app.color or '#6f42c1')"
t-att-disabled="state.phase === 'launching'">
<div class="o_tile_icon">
<img t-if="app.icon" t-att-src="'data:image/png;base64,' + app.icon" alt="icon"/>
<i t-else="" class="fa fa-cube"/>
</div>
<div class="o_tile_name" t-esc="app.name"/>
<div class="o_tile_app_id" t-esc="app.app_id"/>
<div class="o_tile_running" t-if="app.running_session_count">
<span class="badge bg-success" t-esc="app.running_session_count + ' running'"/>
</div>
</button>
</t>
</div>
<div class="o_launcher_empty" t-if="!state.apps.length">
<i class="fa fa-folder-open"/>
<p>No apps available. Ask a manager to add apps in <b>1B OS → Configuration → Apps</b>.</p>
</div>
</div>
<!-- Active surface canvases -->
<div class="o_compositor_surfaces" t-else="">
<t t-foreach="state.surfaces" t-as="surface" t-key="surface.surfaceId">
<div class="o_surface_window"
t-att-class="{ 'is-active': surface.surfaceId === state.activeSurfaceId }"
t-on-click="() => onClickTaskbarItem(surface)">
<div class="o_window_titlebar">
<span class="o_window_title" t-esc="surface.title || surface.appId"/>
<div class="o_window_actions">
<button t-on-click="() => onClickCloseSurface(surface)"
class="o_window_btn o_window_close" title="Close">
<i class="fa fa-times"/>
</button>
</div>
</div>
<div class="o_window_body">
<canvas t-ref="canvas"
t-att-id="'surface-canvas-' + surface.surfaceId"
class="o_surface_canvas"/>
</div>
</div>
</t>
</div>
</div>
<!-- ─── Taskbar (bottom) ──────────────────────────────────── -->
<footer class="o_compositor_taskbar" t-if="state.surfaces.length">
<t t-foreach="state.surfaces" t-as="surface" t-key="surface.surfaceId">
<button class="o_taskbar_item"
t-att-class="{ 'is-active': surface.surfaceId === state.activeSurfaceId }"
t-on-click="() => onClickTaskbarItem(surface)">
<i class="fa fa-window-restore"/>
<span t-esc="surface.title || surface.appId"/>
</button>
</t>
</footer>
<!-- ─── Loading overlay ───────────────────────────────────── -->
<div class="o_compositor_loading" t-if="state.phase === 'loading'">
<i class="fa fa-spinner fa-pulse fa-3x"/>
<p>Loading...</p>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,20 @@
/**
* 1B Space Compositor Shell — component registry
* ===============================================
*
* Registers the CompositorShell component under the
* `one_os_compositor_shell` action tag so Odoo's `ir.actions.client`
* mechanism can find it.
*
* When the user clicks "Open Compositor" in the menu, Odoo looks up the
* `ir.actions.client` record (which has tag="one_os_compositor_shell")
* and calls the corresponding registered component.
*/
import { registry } from "@web/core/registry";
import { CompositorShell } from "./compositor_shell";
// Register the client action
registry.category("actions").add("one_os_compositor_shell", CompositorShell);
export { CompositorShell };
+320
View File
@@ -0,0 +1,320 @@
// 1B Space Compositor Shell — styles
// ====================================
.o_compositor_shell {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
&.is-loading {
opacity: 0.95;
}
// ── Top bar ────────────────────────────────────────────────────────
.o_compositor_topbar {
height: 48px;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.o_topbar_brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: #ffffff;
img { width: 24px; height: 24px; border-radius: 4px; }
}
.o_topbar_session {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #a0a0a0;
code {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
}
.o_topbar_actions {
margin-left: auto;
}
.o_topbar_btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e0e0e0;
transition: all 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
&.active {
background: var(--accent, #6f42c1);
color: white;
}
}
}
// ── Main area ───────────────────────────────────────────────────────
.o_compositor_main {
flex: 1;
position: relative;
overflow: hidden;
}
// ── Launcher grid ───────────────────────────────────────────────────
.o_compositor_launcher {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
.o_launcher_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 20px;
max-width: 1000px;
width: 100%;
}
.o_launcher_tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
position: relative;
color: #e0e0e0;
&:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--accent, #6f42c1);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
}
&:disabled {
opacity: 0.5;
cursor: wait;
}
.o_tile_icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent, #6f42c1);
border-radius: 12px;
color: white;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
i {
font-size: 28px;
}
}
.o_tile_name {
font-weight: 500;
text-align: center;
word-break: break-word;
}
.o_tile_app_id {
font-size: 11px;
color: #888;
font-family: monospace;
}
.o_tile_running {
position: absolute;
top: 8px;
right: 8px;
.badge {
font-size: 10px;
padding: 2px 6px;
}
}
}
.o_launcher_empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 32px;
color: #a0a0a0;
i { font-size: 64px; margin-bottom: 16px; opacity: 0.5; }
p { text-align: center; }
}
}
// ── Surface windows ────────────────────────────────────────────────
.o_compositor_surfaces {
position: absolute;
inset: 0;
padding: 16px;
.o_surface_window {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
bottom: 16px;
background: #2a2a3e;
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
overflow: hidden;
border: 2px solid transparent;
&.is-active {
border-color: #6f42c1;
}
.o_window_titlebar {
height: 36px;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
padding: 0 12px;
font-size: 13px;
flex-shrink: 0;
.o_window_title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.o_window_actions {
display: flex;
gap: 4px;
.o_window_btn {
background: none;
border: none;
color: #e0e0e0;
cursor: pointer;
padding: 4px 8px;
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
&.o_window_close:hover {
background: #dc3545;
color: white;
}
}
}
}
.o_window_body {
flex: 1;
position: relative;
overflow: hidden;
.o_surface_canvas {
width: 100%;
height: 100%;
display: block;
}
}
}
}
// ── Taskbar ────────────────────────────────────────────────────────
.o_compositor_taskbar {
height: 40px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
padding: 0 8px;
gap: 4px;
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.1);
.o_taskbar_item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #e0e0e0;
font-size: 12px;
cursor: pointer;
max-width: 200px;
overflow: hidden;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.is-active {
background: var(--accent, #6f42c1);
color: white;
}
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
}
// ── Loading overlay ────────────────────────────────────────────────
.o_compositor_loading {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 1000;
color: #ffffff;
}
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Old-style asset inheritance doesn't work in Odoo 20 — the SCSS
is already declared in the manifest's `assets` block. This file
is intentionally minimal so we don't double-register. -->
<template id="compositor_shell_assets_placeholder" name="1B Space Compositor Shell (placeholder)"/>
</odoo>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Register the OWL CompositorShell component as a client action.
The SCSS is already declared in the manifest's `assets` block —
we don't use the old inherit_id="web.assets_backend" approach. -->
<record id="compositor_shell_client_action" model="ir.actions.client">
<field name="name">1B Space Compositor</field>
<field name="tag">one_os_compositor_shell</field>
<field name="target">fullscreen</field>
</record>
</odoo>