# 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.