Skip to content

The wire protocol

The contract the PHP side and the browser display server share. PHP declares a widget tree, the serialiser turns it into JSON, the display server renders it, interactions travel back up over HTTP, and rebuilt trees come back down over a websocket. This page is the reference for everything that crosses that boundary.

The node envelope

Every node in a serialised tree is the same four keys:

json
{ "type": "string", "id": "string | null", "props": { }, "children": [ ] }
  • type is the widget kind, the string both registries pair on. An unknown type doesn't throw; it renders a placeholder element and warns in the console, so a version mismatch degrades instead of blanking the desktop.
  • id is the app-assigned event-addressing handle, or null. It lands on the element as data-sx-id.
  • props is a map and always serialises as an object. No props encodes as {}, never [].
  • children is an ordered array. No children is [].

Two fields ride inside props rather than on the envelope. props.key is the reconciliation identity, required on every list child and distinct from id: the morph matches list rows by key, so rows can reorder without being rebuilt. props.events is the per-widget allowlist of which DOM events round-trip.

Events go up over HTTP

A declared event posts to /system-x/event:

json
{ "widget": "clicker", "event": "click", "value": null, "app": "hello", "window": "hello" }
  • widget is the id of the node that fired.
  • event is the event name. The delegated dispatcher round-trips click, submit and change. Overlay widgets (dialogs, menus) emit close and select programmatically through the same POST, because those names don't exist as DOM events on the elements involved.
  • value is the live value an input echoes: a text field's string, a checkbox's boolean, a menu pick's item value. Absent for a click. It is client-controlled, which is why handlers must coerce and validate it rather than trusting it.
  • app is the slug that selects which app class handles it.
  • window is the state and surface key: a ULID for windows launched at runtime, the slug for the seeded boot windows.

The client echoes no app state, only the event. The server hydrates the app's durable properties from its own store, runs the handler, saves, re-renders and broadcasts. The POST itself returns 204 No Content; the new tree does not come back in the response body.

Trees come down over the websocket

The rebuilt tree broadcasts on the user's private channel, user.{userId}, as a desktop.rendered event:

json
{ "app": "hello", "window": "hello", "tree": { "type": "window", "...": "..." } }

tree is a full serialised tree, the whole thing, every frame. There is no server-side diff. The client reconciles: it morphs its live DOM toward the new tree, creating, updating and removing elements as needed, which is what keeps focus, caret and scroll intact across re-renders. The channel is authorised against the real authenticated user, so one user can never receive another's desktop.

The endpoints

  • GET /system-x/desktop?window={id} returns a window's current tree. It's both the boot fetch and the reconnect resync, so a dropped websocket recovers by refetching.
  • POST /system-x/event carries widget events, as above.
  • POST /system-x/wm/launch {app} and POST /system-x/wm/close {window} manage the per-user open-window set. Launch is enforced server side: an uninstalled app 403s regardless of what the client asks for.
  • POST /system-x/wm/geometry persists a window's settled layout: position, size, whether the user has resized it, maximised, minimised, and stacking order.
  • POST /system-x/launcher/layout {layout} saves the launcher arrangement as one document: a list of app entries and folder entries, where a folder has an id, a name and its apps. At boot the stored layout is reconciled against the live app set (unknown slugs dropped, new apps appended) before it reaches the client. The layout covers user apps only; system apps live in the system menu, never the grid.

What stays client-side and what settles to the wire

Window manager interactions are client-owned while they happen. A pointer move during a drag or resize never touches PHP, and the panel's window list is built client-side. But the settled outcome persists: when a drag, resize, maximise or minimise ends, the client fires the geometry endpoint, and a reload restores the layout.

The morph only ever touches content inside a window, never the window's position or size, so a server frame can re-render content mid-drag without fighting the window manager.

Transient overlay state, like an open menu popup, is client-only and never persists. A dialog's open flag, by contrast, is an ordinary durable app property, which is why a dialog that was open comes back open after a reload.