Files
one-os-dev-wayland-html5-co…/08-app-lifecycle.md
T

288 lines
19 KiB
Markdown

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