305 lines
19 KiB
Markdown
305 lines
19 KiB
Markdown
# 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.
|