Files

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:

  1. 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/
  2. Person B implements from spec — never opens @gfld/compositor source.

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:

  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 (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)

  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 (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 (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 (REUSED semantics)

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

  1. 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 surfaceStateChanged event so the Odoo shell can render window decorations correctly.
  2. 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).
  3. notify declared 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).
  4. surfaceCreated fires once on first commit; no separate map/unmap signal in Greenfield — minimization is internal (view.mapped=false) and not propagated. Our MIT approach adds surfaceMapChanged event if needed for taskbar indicators.

Risks flagged (same as Greenfield)

  • XdgWmBase.doPing should use a proper serial (Greenfield uses 0 with a FIXME). Our clean-room implementation should generate proper serials.
  • XdgToplevel.onCommit should validate buffer size vs configured state (Greenfield has commented-out validation). Our implementation should enforce this.
  • XdgSurface.scheduleConfigure's pendingSame argument 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