19 KiB
08 — App Lifecycle (Xdg Shell, Toplevels, Popups, Subsurfaces)
This chapter covers the application lifecycle layer in the MIT approach.
Same architecture as Greenfield (clean-room from spec), with the
UserShellApi integration seam documented in detail since that's where
the Odoo 20 OWL shell hooks in.
Critical: clean-room requirement for roles
The xdg-shell state machine is the highest-risk component of the MIT approach (3 dev-weeks, high risk per Agent P's estimate). Subtle bugs in configure/ack-configure round-trips, popup positioner math, or subsurface sync/desync cascades can cause apps to hang or crash.
Approach:
- Person A writes a behavioral spec from:
- Wayland protocol spec (https://wayland.freedesktop.org/docs/html/)
- xdg-shell.xml protocol description
- wlroots C source (MIT) —
types/xdg_shell/wlr_xdg_*.c - Weston C source (MIT) —
libweston/desktop/xdg-shell.c - Smithay Rust source (MIT) —
src/wayland/xdg_shell/
- Person B implements from spec — never opens
@gfld/compositorsource.
Three role families, one shared abstraction (same as Greenfield)
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 (same as Greenfield)
┌───────────────────────────────────────────────┐
│ 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 (REUSED semantics)
The most important mechanism in xdg-shell. Async protocol-level sync:
- 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 (REUSED semantics)
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 (X-then-Y)
- 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 (REUSED semantics)
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 (REUSED semantics)
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, OPTIONAL)
Legacy path. Reduced feature set. State machine via SurfaceStates enum
(MAXIMIZED/FULLSCREEN/POPUP/TRANSIENT/TOP_LEVEL) and changeState().
Per-surface ping (separate from XdgWmBase's per-client ping).
Recommendation: skip wl_shell in MVP — modern GTK4/Qt6 apps use
xdg-shell exclusively. Only needed for very old apps.
The UserShellApi seam — clean-room spec for Odoo 20 OWL shell
This is the integration point where the Odoo 20 OWL shell hooks in. Designed to match Greenfield's API for compatibility (since it's a reasonable API), but written from spec.
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. |
surfaceAppIdUpdated |
(surface, appId) |
.setAppId() |
xdg_toplevel.set_app_id. |
surfaceActivationUpdated |
(surface, active: boolean) |
gainFocus() / loseFocus() |
Refcounted via focusCount — only fires on 0↔1 transitions. |
notify |
(variant, message) |
(declared, free-form) | Free-form notification channel. |
sceneRefreshed |
sceneId: string |
Scene |
Render finished. |
NEW: surfaceStateChanged |
(surface, state) |
setMaximized/setFullscreen/etc. |
Window state transitions (maximized/fullscreen/minimized/resizing). Greenfield lacks this — Odoo shell needs it for window decorations. |
Actions (shell → compositor) — provided by default
| Action | Effect |
|---|---|
initScene(canvasCreator) |
Bootstraps a WebGL2 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. |
NEW: querySurfaceState(compositorSurface) |
Returns current surface state (maximized/fullscreen/minimized/resizing/activated). Greenfield lacks this. |
NEW: requestKeyframe(compositorSurface) |
Triggers an immediate keyframe request for the encoder. Greenfield has plumbing but doesn't call it. |
Opaque handle types
interface CompositorSurface { id: number; client: CompositorClient }
interface CompositorClient { id: string }
Notable gaps addressed (vs Greenfield)
- No maximize/fullscreen/resize/minimize events in Greenfield's
UserShellApi — the shell can't directly observe window-state
transitions through this API. Our MIT approach adds
surfaceStateChangedevent so the Odoo shell can render window decorations correctly. - No geometry/position event in Greenfield — the shell can't render a frame that hugs surface bounds. Our MIT approach can extend the API later if needed (currently out of MVP scope).
notifydeclared but unused in Greenfield's compositor core — dead interface slot. Our MIT approach implements it as a free-form notification channel (e.g. for "App not responding" warnings).surfaceCreatedfires once on first commit; no separate map/unmap signal in Greenfield — minimization is internal (view.mapped=false) and not propagated. Our MIT approach addssurfaceMapChangedevent if needed for taskbar indicators.
Risks flagged (same as Greenfield)
XdgWmBase.doPingshould use a proper serial (Greenfield uses 0 with a FIXME). Our clean-room implementation should generate proper serials.XdgToplevel.onCommitshould validate buffer size vs configured state (Greenfield has commented-out validation). Our implementation should enforce this.XdgSurface.scheduleConfigure'spendingSameargument inverts meaning depending on whether a microtask is already queued — subtle. Our spec should make this explicit and well-tested.
File reference
Custom (clean-room TS)
| File | Role |
|---|---|
packages/compositor/src/roles/XdgWmBase.ts |
xdg_wm_base global, ping-based unresponsiveness detection |
packages/compositor/src/roles/XdgSurface.ts |
xdg_surface role, configure/ack round-trip |
packages/compositor/src/roles/XdgToplevel.ts |
xdg_toplevel role, window state |
packages/compositor/src/roles/XdgPopup.ts |
xdg_popup role, transient windows, grabs |
packages/compositor/src/roles/XdgPositioner.ts |
xdg_positioner, popup positioning math |
packages/compositor/src/roles/Shell.ts |
wl_shell global (legacy, optional) |
packages/compositor/src/roles/ShellSurface.ts |
wl_shell_surface role (legacy, optional) |
packages/compositor/src/subsurface/Subcompositor.ts |
wl_subcompositor global |
packages/compositor/src/subsurface/Subsurface.ts |
wl_subsurface role, sync/desync mode |
packages/compositor/src/subsurface/SurfaceRole.ts |
SurfaceRole interface |
packages/compositor/src/subsurface/SurfaceChild.ts |
Surface tree child abstraction |
packages/compositor/src/desktop/Desktop.ts |
Floating vs AlwaysFullScreen modes |
packages/compositor/src/userapi/UserShellApi.ts |
The integration seam — events + actions |