Files

19 KiB
Raw Permalink Blame History

08 — App Lifecycle (XDG Shell, Toplevels, Popups, Subsurfaces)

This chapter covers Greenfield's application lifecycle layer: how xdg-shell (toplevels, popups, positioners), wl_shell (legacy), and subsurface compositing are implemented, and how window state transitions are handled. EXCLUDE the actual UI shell (packages/compositor-shell) — focus on the protocol-level lifecycle.

Three role families, one shared abstraction

All surface roles implement SurfaceRole ({ view, optional desktopSurface, onCommit(surface) }). XdgToplevel, XdgPopup, and ShellSurface additionally implement DesktopSurfaceRole, which bridges them to a DesktopSurface (FloatingDesktopSurface or AlwaysFullScreenDesktopSurface, chosen by session.config.mode). The DesktopSurface is where every userShell.events.* emission originates — making it the true integration boundary.

XdgToplevel lifecycle — the headline diagram

                          ┌───────────────────────────────────────────────┐
                          │  wl_surface created (no role, no buffer yet)  │
                          └─────────────────────┬─────────────────────────┘
                                                │ client: xdg_wm_base.get_xdg_surface
                                                │   (errors if surface already has buffer)
                                                ▼
                          ┌───────────────────────────────────────────────┐
                          │ XdgSurface created                             │
                          │  • surface.hasKeyboard/Pointer/Touch = true    │
                          │  • configured=false, configureList=[]          │
                          └─────────────────────┬─────────────────────────┘
                                                │ client: xdg_surface.get_toplevel
                                                │   (errors if surface has another role)
                                                ▼
                          ┌───────────────────────────────────────────────┐
                          │ XdgToplevel created                            │
                          │  • surface.role = xdgToplevel                   │
                          │  • View created                                │
                          │  • DesktopSurface created + init()             │
                          │  • ackConfigure override installed              │
                          └─────────────────────┬─────────────────────────┘
                                                │ client: xdg_toplevel.set_title /
                                                │   set_app_id / set_min/max_size /
                                                │   set_parent  (stored, propagated)
                                                ▼
                          ┌───────────────────────────────────────────────┐
                          │ First wl_surface.commit (buffer === undefined) │
                          │ XdgToplevel.onCommit() → ensureAdded()         │
                          │   • desktopSurface.add()                      │
                          │     → userShell.events.surfaceCreated(...)    │
                          │   • scheduleConfigure(first)                  │
                          │     → microtask → sendConfigure()              │
                          │       → resource.configure(w,h,states)       │
                          │   • added=true                                │
                          └─────────────────────┬─────────────────────────┘
                                                │ client receives configure
                                                │ client: xdg_surface.ack_configure(serial)
                                                ▼
                          ┌───────────────────────────────────────────────┐
                          │ ackConfigure(serial, configure)                │
                          │  • handleAckConfigure trims older serials      │
                          │  • configured=true                             │
                          │  • next.state  = configure.state              │
                          │  • next.size   = configure.size               │
                          └─────────────────────┬─────────────────────────┘
                                                │ client: wl_surface.commit (with buffer)
                                                ▼
                          ┌───────────────────────────────────────────────┐
                          │ ★ MAPPED (steady state) ★                    │
                          │ onCommit():                                    │
                          │   • surface.commitPending()  (state latch)    │
                          │   • xdgSurface.commit()      (geometry latch) │
                          │   • current.state  ← next.state                │
                          │   • current.min/max ← next.min/max            │
                          │   • desktopSurface.commit() → render()         │
                          └─────────────────────┬─────────────────────────┘
                                                │
       ┌────────────────────────────────────────┼────────────────────────────┐
       │                                        │                            │
  user/shell requests                  client requests                destruction
       │                                        │                            │
       ▼                                        ▼                            ▼
 ┌──────────────────────────┐   ┌─────────────────────────────┐  ┌──────────────────────┐
 │ configureMaximized(b)    │   │ setMaximized / unsetMaximized│  │ wl_surface.destroy   │
 │ configureFullscreen(b)   │   │ setFullscreen/unsetFullscreen│  │  or xdg_toplevel.    │
 │ configureActivated(b)    │   │ setMinimized                 │  │  destroy             │
 │ configureResizing(b)     │   │ setParent / setTitle /       │  ├──────────────────────┤
 │ configureSize(w,h)       │   │  setAppId                    │  │ destroy listener:    │
 └────────────┬─────────────┘   │ move / resize / showWindowMenu│  │  • surface.unmap()   │
              │                  └──────────────┬──────────────┘  │  • desktopSurface.   │
              │ each mutates                    │                 │    removed()          │
              │  pending.state / pending.size   │                 │    → surfaceDestroyed │
              ▼                                  │                 │  • view.destroy()     │
 ┌──────────────────────────┐                     │                 └──────────────────────┘
 │ scheduleConfigure(       │                     │
 │   stateCompareEquals(),  │           ┌─────────────────────────────┐
 │   sendConfigure)         │           │ setMinimized →               │
 │  • coalesce via µtask    │           │  desktopSurface.minimize()   │
 │  • if pending==current → │           │  • view.mapped=false         │
 │    skip                  │           │  • seat drops keyboard focus │
 │  • else: push serial,    │           │    if this was the focused   │
 │    emit configure event  │           │    surface                    │
 │                          │           └─────────────────────────────┘
 └────────────┬─────────────┘
              │
              │ cycle repeats: ack_configure → commit → render
              └────────────────────────────────────► back to ★ MAPPED ★

Configure / ack-configure round-trip

This is the most important mechanism to understand in xdg-shell. It's an asynchronous protocol-level sync between the compositor and the client.

Why it's needed

The compositor wants to tell the client "please resize to (800, 600) because the user maximized you". But the client may not be ready to resize, or may want a different size (e.g. the app enforces a 16:9 aspect ratio). The configure/ack round-trip lets the client acknowledge the configure with a serial, then on the next commit, the compositor knows which configure the client has applied.

How Greenfield implements it

  1. Compositor mutates pending.{state,size}.
  2. Coalesces via queueCancellableMicrotask with a stateCompareEquals short-circuit (if pending == current, skip).
  3. Emits resource.configure(w,h,states) with a fresh serial.
  4. Client acks: xdg_surface.ack_configure(serial).
  5. handleAckConfigure trims older serials, sets configured=true, copies configure.{state,size} into next.
  6. Next commit latches next → current.

The configured flag gates buffer attachment: an unconfiguredBuffer error is raised if the client tries to attach a buffer before acking.

XdgPositioner math

anchor selects a point on the anchor rect (in parent window-geometry space); gravity selects which corner/edge of the popup is placed at that point; offset is applied last.

Constraint adjustment cascade

Runs as X-then-Y cascade:

  1. Try flip — swap anchor+gravity via inverseX / inverseY tables, revert if still violating.
  2. Try slide — shift offset by violation delta.
  3. Try resize — shrink sizeRect.

Slides/flips are skipped if popup size on that axis exceeds viewport.

Popup grabs

XdgPopup.grab rejects if:

  • Surface is already mapped, OR
  • Parent isn't either:
    • a toplevel (root of new chain), OR
    • the topmost popup of an existing grab (notTheTopmostPopup error)

seat.popupGrabStart validates the serial against the keyboard/pointer grabSerial, then starts a PopupGrab on both devices. popupGrabEnd walks the chain in reverse calling requestClose() on each (sends popup_done / xdg_popup.popup_done).

Subsurfaces sync/desync

Three-layer mechanism:

  1. cachedState — full pending-state shadow on the subsurface role.
  2. commitCache — flush on parent commit.
  3. effectiveSync getter — cascades: if any ancestor is sync, this subsurface is effectively sync.

Decision matrix

Sync mode Dirty Action
Sync yes Cache to cachedState, flush when parent commits
Sync no No-op (already cached)
Desync yes Flush immediately to parent's pending state
Desync no Acts like a normal surface (commit directly to its own pendingState)

setDesync immediately calls onCommit to flush any pending cache.

wl_shell (legacy path)

Legacy path. Reduced feature set:

  • configureActivated / configureResizing are explicit no-ops.
  • State machine via SurfaceStates enum (MAXIMIZED / FULLSCREEN / POPUP / TRANSIENT / TOP_LEVEL) and changeState().
  • Per-surface ping (separate from XdgWmBase's per-client ping).

The UserShellApi seam — critical for the Odoo 20 OWL shell

Located in UserShellApi.ts, exposed as session.userShell: UserShellApi = { events, actions }. The default createUserShellApi(session) returns events: {} (empty) and a populated actions object. The shell assigns the event callbacks; the compositor always invokes them with ?.() so unhandled events are silent no-ops. This is the single integration seam — the protocol layer above, and the shell UI below.

Events (compositor → shell) — all optional

Event Payload Fired from When
clientCreated CompositorClient WebConnectionHandler, RemoteAppLauncher A new app client connects.
clientDestroyed CompositorClient same A client disconnects.
clientUnresponsiveUpdated (client, unresponsive: boolean) XdgWmBase, ShellSurface Ping (2s timeout, every 5s) — true = hung, false = recovered.
surfaceCreated CompositorSurface FloatingDesktopSurface.add(), AlwaysFullScreenDesktopSurface Toplevel first added to desktop.
surfaceDestroyed CompositorSurface .removed() Window destroyed.
surfaceTitleUpdated (surface, title) .setTitle() xdg_toplevel.set_title / wl_shell_surface.set_title.
surfaceAppIdUpdated (surface, appId) .setAppId() xdg_toplevel.set_app_id / wl_shell_surface.set_class.
surfaceActivationUpdated (surface, active: boolean) gainFocus() / loseFocus() Refcounted via focusCount — only fires on 0↔1 transitions.
notify (variant, message) (declared, no direct emissions) Free-form notification channel.
sceneRefreshed sceneId: string Scene:75 Render finished.

Actions (shell → compositor) — provided by default

Action Effect
initScene(canvasCreator) Bootstraps a WebGL canvas + input/output. One per output.
refreshScene() session.renderer.render() — manual redraw.
destroyScene(sceneId) Destroys a specific scene.
setUserConfiguration(config) Updates scrollFactor, keyboardLayoutName.
closeClient({ id }) Closes a client connection.
activateSurface(compositorSurface) Click-to-focus entry point from the shell's taskbar. Reverse-lookups the surface via session.display.clients[clientId].connection.wlObjects[surfaceId] and calls desktopSurface.activate().

Opaque handle types

interface CompositorSurface { id: number; client: CompositorClient }
interface CompositorClient    { id: string }

toCompositorSurface(desktopSurface) adapts { surface.resource.id, surface.resource.client.id }; lookupSurface(session, compositorSurface) is the reverse walk.

Notable gaps for the Odoo OWL shell

  1. No maximize/fullscreen/resize/minimize events in UserShellApiEvents. The shell can't directly observe window-state transitions through this API — only focus (surfaceActivationUpdated) and metadata (title/appId). For a taskbar this is fine; for window-decorations rendering it is not. Options: (a) extend UserShellApiEvents with surfaceStateChanged; (b) the OWL shell imports DesktopSurface directly and introspects; (c) add a querySurfaceState action.
  2. No geometry/position event — the shell can't render a frame that hugs surface bounds from the shell side without taking over rendering.
  3. notify declared but unused in compositor core — dead interface slot.
  4. surfaceCreated fires once on first commit; no separate map/unmap signal — minimization is internal (view.mapped=false) and not propagated.

Risks flagged

  • XdgWmBase.doPing uses serial 0 (// FIXME use a proper serial) — works only because there's a single outstanding ping per client.
  • XdgToplevel.onCommit has commented-out buffer-size-vs-configured-state validation (lines 140–158) — the compositor trusts clients to honor configured sizes.
  • XdgSurface.scheduleConfigure's pendingSame argument inverts meaning depending on whether a microtask is already queued — subtle and easy to break in maintenance.

File reference

File Role
packages/compositor/src/Shell.ts wl_shell global (legacy)
packages/compositor/src/ShellSurface.ts wl_shell_surface role
packages/compositor/src/Subcompositor.ts wl_subcompositor global
packages/compositor/src/Subsurface.ts wl_subsurface role, sync/desync mode
packages/compositor/src/SurfaceRole.ts SurfaceRole interface
packages/compositor/src/SurfaceChild.ts Surface tree child abstraction
packages/compositor/src/UserShellApi.ts The integration seam — events + actions
packages/compositor/src/XdgWmBase.ts xdg_wm_base global, ping-based unresponsiveness detection
packages/compositor/src/XdgSurface.ts xdg_surface role, configure/ack round-trip
packages/compositor/src/XdgToplevel.ts xdg_toplevel role, window state
packages/compositor/src/XdgPopup.ts xdg_popup role, transient windows, grabs
packages/compositor/src/XdgPositioner.ts xdg_positioner, popup positioning math
packages/compositor/src/desktop/Desktop.ts Floating vs AlwaysFullScreen modes

Next actions (for Odoo 20 OWL shell)

  1. Design createOdooUserShellApi(session) returning the default actions and OWL-signal-bound callbacks for the 8 events.
  2. Decide how to surface maximize/fullscreen/resize/minimize state (extend events vs. introspect DesktopSurface).
  3. Wire clientCreated / clientDestroyed to an Odoo AppContext-style registry for the app launcher UI.
  4. Feed surfaceActivationUpdated into an OWL reactive store as the single source of truth for active-window focus.
  5. initScene(canvasCreator) — OWL shell owns the HTMLCanvasElement lifecycle, one per output.