-
Notifications
You must be signed in to change notification settings - Fork 41
event driven framework
Stable.
The OpenStation plugin is structured as a small, opinionated OS shell plus a set of apps (the recycle bin, the code editor, third-party plugins). The shell does NOT make UX decisions for apps. The shell is a transport — it publishes events apps can subscribe to, exposes synchronous state queries apps can poll, and offers a typed channel bus for app-to-app communication. Apps own their UX policy. When the OS notifies an app that something happened (a window got focused, a peer plugin published a state change), the app decides what to do based on its own internal state.
This document is the contract for that pattern. If you're building
an app on top of OpenStation, read it once. If you're reviewing
a PR that touches anything in src/desktop.ts /
src/native-windows.ts / src/dock.ts, this is the design test:
did we add a UX heuristic to the framework, or did we expose a
hook the app can subscribe to?
%%{init: {'flowchart': {'wrappingWidth': 420}}}%%
flowchart TB
L3["<b>Layer 3 — Activity channels</b><br>peer-to-peer, named, type-safe<br>wp.os.activity.publish / subscribe / filter<br>wp.os.heartbeat.contribute / subscribe<br>wp.os.broadcast / subscribe"]
L2["<b>Layer 2 — Window lifecycle</b><br>os-window-* CustomEvents<br>wp.hooks WINDOW_* actions<br>wp.os.onWindow( id, handlers )"]
L2a["<b>Layer 2a — Window-self channel</b><br>iframe + native, same shape<br>Window.send / Window.on — parent side<br>wp.os.send / wp.os.on — window side"]
L1["<b>Layer 1 — Synchronous state queries</b><br>windowManager.getById( id )<br>windowManager.isActive( id )<br>presence.getStatus( userId )<br>shared stores via createSharedStore"]
L3 --> L2
L2 --- L2a
L2a --> L1
The framework keeps live state on wp.os.* and apps query
it whenever they need a snapshot. Reads never throw, never
race, never block.
const win = wp.os.windowManager.getById( 'my-plugin/inbox' );
const active = wp.os.windowManager.isActive( 'my-plugin/inbox' );
const dot = wp.os.presence.getStatus( authorId );
const store = wp.os.createSharedStore( 'my/state', () => ( { x: 0 } ) );If you're building a "show this thing only when the user can't
already see my window" UI, windowManager.isActive(id) is the
canonical query — it collapses four sub-checks (window exists,
not minimized, focused, on the active virtual desktop) into one
boolean. A window that's focused on a Space the user has since
switched away from does NOT count as active.
For a multi-instance window (multi: true, ids like
${baseId}-2, ${baseId}-3), isActive(id) only ever answers
for one exact id. Use windowManager.isActiveByBaseId(baseId)
instead — it returns true if any instance sharing that
baseId is the currently focused window (still scoped to the
active desktop). This is the query src/recycle-bin/icon-state.ts
switched to so its badge doesn't stay suppressed while the user
is looking at a different recycle-bin instance.
Every native window emits this state machine on the action bus
(wp.hooks) AND as document CustomEvents. Apps pick whichever
flavour fits.
stateDiagram-v2
direction LR
[*] --> opened
opened --> focused
focused --> blurred
blurred --> focused
focused --> minimized
minimized --> restored
restored --> focused
focused --> reopened
blurred --> reopened
minimized --> reopened
reopened --> focused
focused --> closing
closing --> closed
closed --> [*]
note right of reopened
Every wp.os.openWindow call against an id
already open on the active desktop — from any
live state, focused included. The manager
focuses (and un-minimizes) before it fires,
so the window always lands back on focused.
end note
Custom events (filter by e.detail.windowId === MY_ID):
| CustomEvent | Detail |
|---|---|
os-window-opened |
{ windowId, page, title, url } |
os-window-reopened |
{ windowId, baseId, wasMinimized, navigated } — navigated: the request carried a URL the window wasn't showing, so the existing iframe navigated to it in place |
os-window-focused |
{ windowId } |
os-window-blurred |
{ windowId, focusedTo } |
os-window-child-blocked |
{ windowId, childWindowId } — a focus request for windowId went to its child window instead; the owner cannot come to the front while the child is open |
os-window-closing |
{ windowId, element } |
os-window-closed |
{ windowId } |
os-window-changed |
{ windowId?: string, reason: 'moved' | 'resized' | 'state' | 'cascade' | 'tile', state?: WindowState } — batch-arrange dispatches ('cascade' / 'tile') omit windowId/state
|
Same payloads on wp.hooks actions:
HOOKS.WINDOW_OPENED, …_FOCUSED, …_BLURRED, …_CHILD_BLOCKED,
…_CLOSED,
…_MINIMIZED, …_RESTORED, …_MAXIMIZED, …_UNMAXIMIZED,
…_FULLSCREEN_ENTERED/EXITED, …_REOPENED, plus geometry events
(…_RESIZED, …_BODY_RESIZED, …_BOUNDS_CHANGED,
…_DRAG_START/END, …_RESIZE_START/END).
Per-window facade. wp.os.onWindow(id, handlers, options?)
is a typed wrapper that binds handlers and filters by id for you.
Two lifetime modes:
// One-shot per-instance. Auto-unsubscribes on `closed` — the
// next open of the same id needs a fresh subscribe.
const off = wp.os.onWindow( 'my-plugin/inbox', {
focused: () => clearAttention(),
closed: () => recordSession(),
} );
// App-lifetime — keeps firing every time the window reopens.
// Use this for badge policies, DND rules, anything that must
// react to every open + close cycle of the page.
wp.os.onWindow(
'my-plugin/inbox',
{
opened: () => repaintBadge(),
focused: () => repaintBadge(),
blurred: () => repaintBadge(),
minimized: () => repaintBadge(),
restored: () => repaintBadge(),
closed: () => repaintBadge(),
reopened: () => repaintBadge(),
},
{ persistent: true },
);The persistent footgun. Without persistent: true, your
handler stops firing after the first close. A badge policy that
subscribes once at boot but uses the default mode will look
broken the second time the user opens the window. If the rule
is "react to every state change for the lifetime of the tab,"
you want persistent: true.
Lifecycle tells you that a window opened, focused, blurred, closed. The window-self channel tells you what's happening INSIDE the window content. Two methods, deliberately unifying iframe and native windows:
// Outside the window — anywhere in the shell.
const win = wp.os.windowManager.getById( 'wpdc-editor' );
win.send( 'editor:open-file', { path: 'foo.php', line: 42 } );
const off = win.on( 'editor:saved', ( payload ) => repaint( payload ) );
// Inside an iframe (chromeless wp-admin OR `iframeContent` native body).
wp.os.send( 'editor:saved', { path, size } );
const off = wp.os.on( 'editor:open-file', ( { path, line } ) => {
openFile( path, line );
} );
// Inside a native render callback — second arg carries the
// window-scoped binding so you don't have to look up your own id.
wp.os.registerWindow( {
id: 'my-tool',
render: ( body, { window } ) => {
body.querySelector( 'button' ).addEventListener( 'click', () => {
window.send( 'tool:saved', {} );
} );
const off = window.on( 'tool:reset', () => { /* … */ } );
return () => off();
},
} );The framework picks the delivery mechanism. Iframe windows
go via postMessage (the bridge translates Window.send to
os-window-send and wp.os.send back up to
os-window-publish). Native windows route in-process
through the channel bus. Plugin authors never branch on
window type and never reach for postMessage directly.
Subscribers auto-clean. When a window closes, every
Window.on and native windowApi.on subscriber bound to its id
is dropped. Reopening the same id starts with an empty
subscriber set — no stale callbacks fire against the new
instance.
wp.os.connect() works identically for both. The
peer-to-peer connection bridge — used when one window wants to
talk to another — routes through the same channel bus when the
target is native. It used to silently no-op on native
targets; now conn.send / conn.subscribe reach the render
context's windowApi listeners. Same onOpen / isOpen /
disconnect semantics for both kinds.
What this replaces. Window.iframeSend is gone (the
unified Window.send does the same job, with
the same pre-load FIFO buffering, while also working for
pure-native windows). wp.os.iframe.publish/subscribe
stays for the multi-listener handshake-aware wp.os.connect()
flow, but new code should reach for wp.os.send/on first —
same call regardless of whether the window content is an iframe
or a native render.
For app state changes that PEER apps might want to know about (unread counts, item counts, mode toggles, …):
// Publish.
wp.os.activity.publish( 'inbox/unread-changed', { total: 5 } );
// Subscribe.
const off = wp.os.activity.subscribe(
'inbox/unread-changed',
( { total } ) => repaintMyWidget( total ),
);
// Filter — let other plugins mutate the value before peers see it.
const safe = wp.os.activity.filter(
'inbox/outgoing-payload',
payload,
{ author: 7 },
);Channels follow the convention <plugin>/<event>. The runtime
routes them through os.activity.<channel> on the hook
bus, so devtools can list activity traffic as a discrete group.
Hook names can't contain a slash (@wordpress/hooks rejects the
registration), so the channel separator becomes a period there:
my-plugin/thing-happened lands on
os.activity.my-plugin.thing-happened. Only relevant if
you register through raw wp.hooks instead of the API below.
Type the payload by augmenting ActivityChannelMap in your own
.d.ts:
import type {} from 'openstation/activity';
declare module 'openstation/activity' {
interface ActivityChannelMap {
'my-plugin/something-happened': { id: number; reason: string };
}
}Built-in channels. Every framework primitive that publishes
mirrors here so plugins can subscribe through one unified API. The
os/ namespace is the shell's own — subscribe and filter freely,
but publish your events under your plugin's slug:
| Channel | Filterable? | When it fires |
|---|---|---|
os/toast-requested |
Yes — cancel: true to drop, mutate to rewrite |
Pre-show on every showToast(). |
os/toast-shown |
No (post-render) | After the toast lands in the DOM. |
os/notification-requested |
Yes — cancel: true to drop, mutate to rewrite |
Pre-render on every wp.os.notify(). |
os/notification-shown |
No (post-render) | After the notification (or its toast fallback) renders — payload carries fallback: 'toast' | null. |
os/window-attention-requested |
Yes — cancel: true for DND, mutate mode/durationMs to scale |
Pre-attention on every Window.requestAttention() (which then routes the filtered result to the rails' setAttention()). Direct dock.setAttention() calls bypass the filter. |
os/badge-changed |
No | Every setBadge() on dock / taskbar / icons. Payload carries rail: 'dock' | 'taskbar' | 'icon' so a single subscriber can compose across surfaces. |
os/open-requested |
No | Every wp.os.openWindow(), BEFORE deciding opened vs reopened. Carries source. |
os/presence-changed |
No | Every presence transition (mirror of the os-presence-changed CustomEvent). |
os/presence-snapshot-applied |
No | After every presence batch — { applied, transitions }. |
os/game-score-recorded |
No | After a game's submitScore() write resolves (free play and challenge completion both). Games play in their own window, so this is how a leaderboard in another window learns it went stale. Payload carries challengeId on the completion path. |
os/upload-hud-complete |
No | After a file dropped on the shell finishes uploading — { filename, attachmentId }. Published by the progress HUD, not the uploader: the upload runs on XHR (the only transport that reports determinate progress) and so never routes through wp.os.fetch, which makes this the bus's only view of a completed drop. |
Content-change announcements. The os.<type>.changed topic
family is the bus's highest-traffic convention, and
wp.os.announceContentChange( type, action, ids, source? ) is its
typed producer. The rule of thumb: a window that mutates content
through its own REST endpoints must announce — the shell cannot
see third-party REST traffic, so without the announcement the
Recycle Bin, its dock badge and every other list window only learn
of the change from the Heartbeat catch-all, 15–60 seconds later.
See javascript-reference.md → announceContentChange
for the envelope.
Activity ↔ broadcast mirror. wp.os.broadcast(topic, payload)
publishes both onto the broadcast bus (cross-iframe, cross-tab)
AND onto the activity bus (in-tab) under the same topic name.
Use broadcast when peers might be in another iframe / tab
(the recycle bin's os.data-changed topic is the
canonical example); use activity.publish when you only need
in-tab fan-out.
Because the mirror is verbatim and hookName() collapses / to
., a channel and a topic that differ only in that separator
land on the same hook: os/badge-changed and a topic
os.badge-changed both resolve to
os.activity.os.badge-changed. The two namespaces have always
shared one hook space — pre-rebrand it was desktop-mode on both
sides — and nothing in-tree collides. Just don't name a broadcast
topic after a built-in channel.
A specialised pub/sub for things that ride the WordPress
Heartbeat (heartbeat-send / heartbeat-tick). Previously every
feature that wanted a per-tick payload bound the jQuery events
itself; with the bus, multiple plugins compose without any
boilerplate.
// Outgoing — add a field to the next heartbeat-send.
const off = wp.os.heartbeat.contribute(
'my-plugin/active',
() => isActive() ? true : undefined, // undefined = skip this tick
);
// Incoming — read a field on the heartbeat-tick response.
const offIn = wp.os.heartbeat.subscribe(
'my-plugin/payload',
( v ) => applyServerSnapshot( v ),
);Last-writer-wins for contribute. Re-contributing the same
field replaces the previous supplier, so a plugin can swap
policies cleanly. Many subscribers compose for subscribe —
the bus dispatches each registered subscriber for an incoming
field. Errors in any one supplier or subscriber are logged and
isolated; one bad handler can't strand peers.
The framework's own features are built on top: presence
contributes openstation_presence_active + openstation_user_active
and subscribes to openstation_presence. Plugins that need a
per-tick delivery story (live counts, server-driven badges,
session keep-alives) plug into the same bus.
The framework's job is to publish events, expose state, and route data between plugins. The framework's job is NOT to make UX decisions on the plugin's behalf.
An earlier version of the framework had the Dock auto-suppress badges while a window was active. We reverted that — the reasons:
- The Dock can't know what every app's badge means. A "5 unread notifications" badge SHOULD suppress when the inbox is open. A "5 failed deploys" badge probably SHOULDN'T even when the deploy console is open — the user wants to see the count regardless.
- Apps that disagree with the framework heuristic can't override it without forking. The framework can change the heuristic and break apps silently.
- Two writers (the Dock's auto-paint + the app's setBadge) end up fighting over the same DOM. Race conditions, flicker.
The new pattern: the app subscribes to its own window's
lifecycle and calls dock.setBadge(id, count) with whatever count
makes sense for its current state. The Dock paints whatever
the app asked for — period.
// A canonical "show 0 while my window is active, otherwise the
// real count" badge owned by the plugin (NOT the framework).
const WINDOW_ID = 'my-plugin/inbox';
function repaintBadge() {
const total = myPlugin.getUnreadCount();
const active = wp.os.windowManager.isActive( WINDOW_ID );
const visible = active ? 0 : total;
// Plugin's policy. The rails just render whatever we pass.
// The rail that owns the id paints, the others silently
// no-op. One activity event fires.
wp.os.dock?.setBadge?.( WINDOW_ID, visible );
wp.os.sideDock?.setBadge?.( WINDOW_ID, visible );
wp.os.icons?.setBadge?.( WINDOW_ID, visible );
}
// React to either axis changing.
wp.os.activity.subscribe( 'inbox/unread-changed', repaintBadge );
[ HOOKS.WINDOW_OPENED, HOOKS.WINDOW_FOCUSED, HOOKS.WINDOW_BLURRED,
HOOKS.WINDOW_MINIMIZED, HOOKS.WINDOW_RESTORED, HOOKS.WINDOW_CLOSED,
HOOKS.WINDOW_REOPENED ].forEach( ( h ) =>
wp.os.hooks.addAction( h, 'my-ns', ( p ) => {
if ( p.windowId === WINDOW_ID ) {
repaintBadge();
}
} )
);
repaintBadge(); // initial paintMulti-instance windows (multi: true) need
windowManager.isActiveByBaseId( baseId ) instead of isActive( id ) in repaintBadge() above — otherwise the badge only
suppresses for the exact instance id first opened, and stays
visible while the user is looking at instance -2 or -3. See
src/recycle-bin/icon-state.ts for the
full pattern, including matching lifecycle events across every
instance id sharing the base.
There is no wp.os.taskbar accessor. The three badge
rails are wp.os.dock (the primary bottom rail),
wp.os.sideDock (the Classic-layout left rail — null in
Unified), and wp.os.icons (wallpaper shortcuts).
The rail discriminator on emitted events is a separate axis:
the bottom-anchored primary dock stamps rail: 'taskbar' onto
the events it emits (e.g. os/badge-changed), while
sideDock stamps rail: 'dock' and the icon rail rail: 'icon'.
Don't read window state from a state mirror you populate via
CustomEvents. The mirror drifts when events drop or fire out of
order. Use windowManager.getById/isActive for synchronous reads.
Don't add UX heuristics inside framework primitives. If the behaviour you want is "show 0 in the badge when the window is active", own it in your app. The framework should keep the door open for apps that want different policies.
Don't build a bespoke window.__myPluginShared slot when you
need to share state across bundles. Use
wp.os.createSharedStore('your-plugin/key', …) — same
shape, dedupes for free, namespaced.
Don't wire a document.addEventListener('os-window-*', …)
when wp.os.onWindow(id, handlers) already does the
windowId filter for you. Faster to write, easier to type.
Not every transition is a window transition. The framework announces OS-level state changes on the same dual surface (document CustomEvent + hook action) so apps decide their own policy:
| CustomEvent | Hook | Meaning |
|---|---|---|
os-auth-lost |
os.auth.lost |
The login session expired (Heartbeat wp-auth-check verdict). Pause pollers; requests will 401. |
os-auth-restored |
os.auth.restored |
The session is back and cached nonces are fresh again — resume + re-sync. |
Consistent with the framework's transport-not-policy rule, the shell doesn't pause anyone's poller itself — it tells you, you decide. See Session expiry & recovery for the full contract.
- JS reference — full per-API docs.
- Hooks reference — full PHP filter / action signatures.
-
docs/examples/— copy-paste recipes. - In-tree consumer worth reading:
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config