19 KiB
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
- Compositor mutates
pending.{state,size}. - Coalesces via
queueCancellableMicrotaskwith astateCompareEqualsshort-circuit (if pending == current, skip). - Emits
resource.configure(w,h,states)with a fresh serial. - Client acks:
xdg_surface.ack_configure(serial). handleAckConfiguretrims older serials, setsconfigured=true, copiesconfigure.{state,size}intonext.- 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:
- Try flip — swap anchor+gravity via
inverseX/inverseYtables, revert if still violating. - Try slide — shift offset by violation delta.
- 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 (
notTheTopmostPopuperror)
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:
cachedState— full pending-state shadow on the subsurface role.commitCache— flush on parent commit.effectiveSyncgetter — 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/configureResizingare explicit no-ops.- State machine via
SurfaceStatesenum (MAXIMIZED / FULLSCREEN / POPUP / TRANSIENT / TOP_LEVEL) andchangeState(). - 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
- 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) extendUserShellApiEventswithsurfaceStateChanged; (b) the OWL shell importsDesktopSurfacedirectly and introspects; (c) add aquerySurfaceStateaction. - No geometry/position event — the shell can't render a frame that hugs surface bounds from the shell side without taking over rendering.
notifydeclared but unused in compositor core — dead interface slot.surfaceCreatedfires once on first commit; no separate map/unmap signal — minimization is internal (view.mapped=false) and not propagated.
Risks flagged
XdgWmBase.doPinguses serial 0 (// FIXME use a proper serial) — works only because there's a single outstanding ping per client.XdgToplevel.onCommithas commented-out buffer-size-vs-configured-state validation (lines 140–158) — the compositor trusts clients to honor configured sizes.XdgSurface.scheduleConfigure'spendingSameargument 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)
- Design
createOdooUserShellApi(session)returning the defaultactionsand OWL-signal-bound callbacks for the 8 events. - Decide how to surface maximize/fullscreen/resize/minimize state (extend
events vs. introspect
DesktopSurface). - Wire
clientCreated/clientDestroyedto an OdooAppContext-style registry for the app launcher UI. - Feed
surfaceActivationUpdatedinto an OWL reactive store as the single source of truth for active-window focus. initScene(canvasCreator)— OWL shell owns theHTMLCanvasElementlifecycle, one per output.