288 lines
19 KiB
Markdown
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 |
|