-
Notifications
You must be signed in to change notification settings - Fork 41
example connect to window
End-to-end recipe for the connection bridge: a plugin adds a button in the Gutenberg window's title bar, the button shows a dropdown of other open windows, hovering an item highlights the candidate window, clicking opens a wp.os.connect() channel, and Gutenberg keystrokes stream into a preview window in real time.
This is the canonical use case for the four connection APIs working together:
-
wp.os.registerTitleBarButton— UI entry point -
Window.setHighlight— visual feedback -
wp.os.connect— parent-side channel -
wp.os.iframe.publish/subscribe/onConnection— iframe-side channel
// my-plugin.php
add_action( 'admin_enqueue_scripts', function () {
// Parent-shell script — the title-bar button + the connect logic.
wp_register_script(
'my-plugin-titlebar',
plugins_url( 'js/titlebar.js', __FILE__ ),
array( 'openstation' ),
'1.0.0',
true
);
wp_enqueue_script( 'my-plugin-titlebar' );
// Iframe-side script — runs inside the Gutenberg page, publishes
// editor state. Use the standard admin enqueue; the chromeless
// bridge already wires up `wp.os.iframe.*`.
wp_register_script(
'my-plugin-iframe',
plugins_url( 'js/iframe.js', __FILE__ ),
array( 'wp-data', 'wp-edit-post' ),
'1.0.0',
true
);
wp_enqueue_script( 'my-plugin-iframe' );
} );
// Tell the shell our titlebar script registers buttons — gets the
// live-refresh injection on plugin activation.
openstation_register_titlebar_button_script( 'my-plugin-titlebar' );// js/titlebar.js
wp.os.ready( () => {
wp.os.registerTitleBarButton( {
id: 'live-preview/connect',
label: 'Live preview',
icon: 'dashicons-visibility',
// Only show on Gutenberg windows.
match: ( w ) => /post(?:-new)?\.php/.test( w.config.url ?? '' ),
owner: 'my-plugin-titlebar',
// Custom render — we own the host so we can wire a popover
// dropdown without fighting `<os-window-button>` defaults.
render: ( host, hostWindow ) => {
host.addEventListener( 'click', () =>
showCandidatesPopover( host, hostWindow ),
);
},
} );
} );
function showCandidatesPopover( anchor, hostWindow ) {
// Build a quick popover listing every other open window.
const popover = document.createElement( 'os-menu' );
popover.style.position = 'absolute';
popover.style.top = `${ anchor.getBoundingClientRect().bottom }px`;
popover.style.left = `${ anchor.getBoundingClientRect().left }px`;
document.body.appendChild( popover );
const others = wp.os.windowManager
.getAll()
.filter( ( w ) => w.id !== hostWindow.id );
others.forEach( ( target ) => {
const item = document.createElement( 'os-menu-item' );
item.textContent = target.config.title;
// Hover-preview → highlight the candidate window.
item.addEventListener( 'mouseenter', () => target.setHighlight( 'preview' ) );
item.addEventListener( 'mouseleave', () => target.setHighlight( null ) );
// Click → open the connection.
item.addEventListener( 'click', () => {
target.setHighlight( null );
popover.remove();
wireUpLivePreview( hostWindow, target );
} );
popover.appendChild( item );
} );
// Dismiss on outside click.
setTimeout( () => {
const dismiss = ( e ) => {
if ( ! popover.contains( e.target ) ) {
popover.remove();
others.forEach( ( w ) => w.setHighlight( null ) );
document.removeEventListener( 'click', dismiss );
}
};
document.addEventListener( 'click', dismiss );
}, 0 );
}
function wireUpLivePreview( gutenbergWin, previewWin ) {
// Subscribe to Gutenberg edits + forward into the preview window.
const editorConn = wp.os.connect( gutenbergWin.id, {
topics: [ 'gutenberg:content' ],
} );
// The preview window is also an iframe — open a second connection
// and `send` the latest content on every keystroke.
const previewConn = wp.os.connect( previewWin.id );
editorConn.subscribe( 'gutenberg:content', ( html ) => {
previewConn.send( 'preview:html', html );
} );
// Tear both down when either closes.
editorConn.subscribe( '*', ( _p, m ) => {
if ( m.topic === 'gutenberg:closed' ) {
editorConn.disconnect();
previewConn.disconnect();
}
} );
}// js/iframe.js — runs inside the Gutenberg iframe.
if ( window.wp?.os?.iframe ) {
wp.os.iframe.onConnection( () => {
// Start emitting only when somebody connects — saves work.
const editor = wp.data.select( 'core/editor' );
let lastContent = '';
wp.data.subscribe( () => {
const next = editor.getEditedPostContent();
if ( next === lastContent ) {
return;
}
lastContent = next;
wp.os.iframe.publish( 'gutenberg:content', next );
} );
} );
}If the preview is also an iframe (a custom plugin page), have it subscribe to preview:html:
// js/preview-iframe.js — inside the preview window.
if ( window.wp?.os?.iframe ) {
wp.os.iframe.subscribe( 'preview:html', ( html ) => {
document.querySelector( '#preview-target' ).innerHTML = html;
} );
}That's the whole live-preview flow: keystroke in Gutenberg → wp.data.subscribe fires → iframe.publish('gutenberg:content', html) → parent shell routes the message → preview window's iframe.subscribe('preview:html') paints it.
If your preview window is itself an iframe pointing at a custom plugin URL, skip the manual body.appendChild( iframe ) dance. The iframeContent shorthand lets registerWindow own the iframe lifecycle:
// `registerWindow` is async — it resolves to the DesktopWindow, so the
// enclosing function must be `async`.
const previewWin = await wp.os.registerWindow( {
id: 'live-preview/preview',
title: 'Live Preview',
icon: 'dashicons-visibility',
width: 720,
height: 720,
iframeContent: {
url: '/wp-admin/admin.php?page=my-preview-page',
bridge: true, // auto-inject `wp.os.iframe.*`
onMessage: ( payload ) => {
// Source-checked already by the shell — no need to validate event.source.
},
},
} );
// Push state into the preview window. `Window.send` is safe to
// call before the iframe has finished loading — pre-load sends
// queue and flush in FIFO order once the iframe-bridge announces
// itself ready.
previewWin.send( 'init', { theme: 'dark' } );
editorConn.subscribe( 'gutenberg:content', ( html ) => {
previewWin.send( 'preview:html', html );
} );If you want the live stream to collapse pre-load intermediates to the freshest snapshot (so three keystrokes during iframe load become one), keep your own latest-only ref outside the queue:
let latest = null;
let queued = false;
editorConn.subscribe( 'gutenberg:content', ( html ) => {
latest = html;
if ( queued ) return;
queued = true;
queueMicrotask( () => {
previewWin.send( 'preview:html', latest );
queued = false;
} );
} );Then inside my-preview-page:
wp.os.on( 'preview:html', ( html ) => {
document.querySelector( '#preview-target' ).innerHTML = html;
} );That's the whole preview frame — no postMessage plumbing, no source-check, no load-vs-listener race.
If the editor sidebar (running inside the Gutenberg iframe) wants to initiate the connection rather than wait for the parent's button click:
// Inside the Gutenberg iframe:
const conn = await wp.os.iframe.requestConnection( {
topics: [ 'wpglp:content' ],
} );
// `conn` = { id, topics } — the parent already opened a connection back to us.
// From here, just `publish` like normal.Parent-side, plugins can intervene with the os.iframe.connection-request filter:
wp.os.hooks.addFilter(
'os.iframe.connection-request',
'my-plugin/gate',
( accept, ctx ) => {
// ctx = { windowId, requestId, topics }
if ( ctx.topics.includes( 'destructive:topic' ) && ! userIsAdmin() ) {
return false; // reject
}
return accept;
},
);Default behaviour is accept-with-original-topics — the iframe is same-origin and it asked, so the gate is opt-in.
-
Origin guard: every postMessage flowing through the bridge is
targetOrigin-checked against the shell's own origin. Cross-origin iframes can't talk to the bridge — by design. -
Topic naming: prefix with your plugin slug (
gutenberg:content,live-preview:html). Two plugins picking the same topic name will subscribe to each other's traffic. - Sanitisation: payloads pass through verbatim. The shell does NOT sanitise. If the payload could include user-typed HTML rendered as innerHTML in the receiver, sanitise on the publish side or use safer DOM construction in the receiver.
-
Tear-down: the shell auto-disconnects every connection targeting a window when that window closes (
onClosereason:'window-closed'). Per-topic unsubscribe is the caller's responsibility. -
Wildcard subscription:
subscribe( '*', cb )fires for every published payload. Cheap to wire up, expensive when the topic carries one event per keystroke — use sparingly.
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