# 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 ```typescript 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 |