-
Notifications
You must be signed in to change notification settings - Fork 42
bridge protocol
A single page that maps every layer of the cross-window connection bridge end-to-end. You shouldn't need this to build a plugin — the public APIs in javascript-reference.md are sufficient. Read this when you're debugging a stuck handshake, building unusual integrations (cross-origin frames, custom transports), or contributing to the shell itself.
PARENT SHELL IFRAME
(one per browser tab) (one per window)
──────────────── ────────────
Plugin code
│
│ wp.os.connect( id, opts )
▼
┌─────────────────────────────┐ ┌──────────────────────┐
│ src/connection/index.ts │ │ iframe-bridge.js │
│ ──────────────── │ ── handshake ──────────▶ │ (or inline bridge │
│ • createConnectionBridge │ ◀── handshake-ack ── │ from includes/ │
│ • _connections (Map) │ │ render/chromeless- │
│ • _connectionsByTarget │ ── publish ───────────▶ │ bridge.php) │
│ • _syntheticIframes │ ◀── publish ──────── │ • wp.os.iframe │
│ • routeIncomingFromIframe │ ── disconnect ────────▶ │ .publish │
│ • handleConnectionRequest │ ◀── disconnect ───── │ .subscribe │
└────────────┬────────────────┘ │ .onConnection │
│ │ .requestConnection│
│ window.__openStationConnectionBridge │ │
│ (side-channel install) │ │
▼ │ │
┌─────────────────────────────┐ │ │
│ src/window/iframe-bridge.ts │ │ │
│ handleWindowMessage │ ◀── postMessage events ── │ │
│ (per-Window listener) │ │ │
└────────────┬────────────────┘ │ │
│ │ │
│ — OR for native windows with `iframeContent`: │
│ │ │
┌────────────▼────────────────┐ │ │
│ src/native-windows.ts │ │ │
│ buildIframeContentRender │ ◀── postMessage events ── └──────────────────────┘
│ (synthesised iframe holder)│
│ • registerSyntheticIframe │
│ • forwards bridge-* msgs │
│ • shell-managed lifecycle │
└─────────────────────────────┘
Two protocol families flow over the same postMessage boundary:
-
Window-self channel (
os-window-*) — the unifiedWindow.send/onAPI. The first thing most plugin code reaches for. Single channel, no handshake, scoped to one window's content. -
Connection bridge (
os-bridge-*) — multi-connection peer-to-peer with handshakes, used bywp.os.connect()/wp.os.iframe.requestConnection().
Both sides validate event.origin against window.location.origin (or the iframe URL's resolved origin for iframeContent synthesised iframes); messages without a recognized prefix are dropped.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-window-send |
parent → iframe | { channel, payload } |
Posted by Window.send( channel, payload ). The iframe-side bridge fires every wp.os.on( channel, cb ) subscriber. |
os-window-publish |
iframe → parent | { channel, payload } |
Posted by wp.os.send( channel, payload ) inside the iframe. The parent forwards to every Window.on( channel, cb ) subscriber for this window. |
Native (non-iframe) windows skip postMessage entirely — Window.send and the render's windowApi.send reach the parent / native channel-bus registries directly. Plugin authors don't need to know the window's render strategy; the framework picks the right delivery path.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-content-identity |
iframe → parent | { identity: WindowContentRef | null } |
Which object this admin page shows — { type, id, label?, root?, links?, related? }, resolved server-side in real admin context (post/page/CPT editors are roots and carry their content's internal hyperlinks as links; comment-edit and attached-media screens arrive pre-rooted at their parent post; the openstation_window_content_identity PHP filter extends detection). related carries the ready-to-open navigation targets behind the title bar's "Related" button — { id, group, label, url, groupLabel?, icon?, count? } entries built for posts/pages and filterable via openstation_window_related_entities. Feeds wp.os.relations and the window-link visuals. |
Emitted on every chromeless page load, including identity: null — a full-page navigation away from an identified screen must clear the stale identity, and since every iframe navigation re-runs admin_footer, that same emission doubles as the re-announce-on-navigate path. It fires at the very TOP of the bridge script (right after the top-frame escape hatch, before any feature block) so a page-specific runtime failure elsewhere in the bridge can never cost the shell its window relations — unlike os-ready, which intentionally posts last.
Re-announced after block-editor saves: Gutenberg saves over REST without navigating, so the bridge also watches the core/editor save lifecycle and, after every real (non-autosave) save, refetches a server-recomputed identity from GET /desktop-mode/v1/content-identity?post={id} (capability-gated to edit_post; both identity filters run there with $screen = null) and posts this same message again. The parent engine diffs repeats, so identical re-announcements are free. See docs/examples/window-links.md.
Save broadcast: on the same save-success edge the watcher also posts an upstream { type: 'os-broadcast', topic: 'os.<postType>.changed', payload: { source: 'editor', action: 'created' | 'updated', ids: [ postId ] } } to the parent, which fans it out to every window — list windows showing that type refresh instantly. action is 'created' exactly when the post was still new on the tick the save started. This is the block editor's leg of the content-change realtime layer (includes/content-changes.php); form-POST → redirect flows are covered server-side by the chromeless-footer emitter instead. The iframe-side consumer is the soft-reload handler: edit.php / upload.php / edit-comments.php are matched generically by list type, and non-standard list screens (the HPOS wc-orders list) are declared via the PHP-printed /*__OPENSTATION_SOFT_RELOAD_EXTRAS__*/ placeholder, filterable server-side through openstation_soft_reload_rules.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-bridge-handshake |
parent → iframe | { connectionId, targetWindowId, topics } |
Open a new connection. Iframe must ack before parent flushes its message queue. The targetWindowId is the host window's id — the iframe stores it for wp.os.iframe.windowId / whenWindowId(). |
os-bridge-handshake-ack |
iframe → parent | { connectionId } |
Iframe acknowledges. Parent fires HOOKS.CONNECTION_OPENED + flushes. |
os-bridge-publish |
both ways | { connectionId, topic, payload } |
Pub/sub message. Wildcard subscribers ('*') see every topic. |
os-bridge-disconnect |
both ways | { connectionId } |
Tear the connection down. Idempotent. |
os-bridge-connection-request |
iframe → parent | { requestId, topics } |
wp.os.iframe.requestConnection(). Parent fires HOOKS.IFRAME_CONNECTION_REQUEST filter; default accept. |
os-bridge-connection-ack |
parent → iframe | { requestId, accepted, connectionId? | reason? } |
Reply to a request — accepts hand back the new connection id, rejects supply a reason. |
When the connection bridge targets a native window, no postMessages are exchanged — connect() opens synchronously and conn.send/subscribe route through the same in-process channel bus that powers Window.send/on. Same onOpen / isOpen / disconnect semantics, no observable difference to the caller.
When the user drags a file from the host operating system onto a chromeless admin iframe, the chromeless bridge (and the standalone iframe bridge) intercepts the drop event before the browser's default handler navigates the iframe, and forwards the raw File[] to the parent shell.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-file-drop |
iframe → parent | { files: File[], x: number, y: number } |
Native-OS file drop captured inside the iframe. Same-origin only — postMessage preserves File identity. The parent's OsFileDropManager resolves the source iframe's data-window-id via MessageEvent.source and routes the files through the drop pipeline. |
The forwarder listens in bubble phase at the iframe's document, so any in-page drop receiver runs first and gets the chance to claim the drop. Three bail conditions, in order:
-
Curated allowlist —
.components-drop-zone,[data-drop-zone],.uploader-window,.media-frame-contentalways yield, so Gutenberg's media uploader and the legacy media library keep working as before even on edge cases that skip the spec dance. -
event.defaultPrevented === true— any inner handler that calledpreventDefault()ondragoverordropis signalling ownership per the HTML5 drag-and-drop contract. The forwarder yields. Third-party plugin drop zones (e.g. "Administrador de archivos WP") that already work in classic admin keep working untouched inside OpenStation iframes — no opt-in required. -
A native file input under the drop — an
<input type="file">the pointer is on, or the single file input inside theform.wp-upload-formbox the pointer is in (Core's Upload Plugin and Upload Theme), receives the files the way a drop outside the shell would: the bridge setsfiles, firesinputandchange(Core'scommon.jsenables Install Now on the latter) and claims the event. A non-multipleinput takes the first file only. A disabled or unrendered input, or a box with several, does not qualify — Media › Add New keeps its no-JS#async-uploadhidden behind plupload, and a drop there still escalates. While a file drag hovers a qualifying box the bridge stampsdata-os-file-drop-activeon it, andassets/css/chromeless.cssdraws the outline.
Only drops where none of the three fires (the empty page background, or an inner handler that never called preventDefault()) escalate to the shell.
Native drag events don't cross iframe boundaries, so when the user holds any drag (an OS file, an image lifted off another admin page, a text selection) over an iframe window, the parent shell can't see the hover. Both bridges (inline chromeless + standalone) forward a throttled heartbeat while dragover fires inside the iframe, so the shell's focus-on-drag-hover module can raise the hovered window after its ~250 ms dwell (see the os.window.focus-on-drag-hover filter in javascript-reference.md).
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-drag-hover |
iframe → parent | { payloadType: 'os-file' | 'external' } |
"A drag is currently hovering me." Throttled to one message per 150 ms. Purely observational — the forwarder never calls preventDefault() and carries no coordinates or payload data; the parent resolves the hovered window from MessageEvent.source (the sender iframe is the hovered window). The parent resets its hover state when heartbeats stop (~1 s watchdog), so no end message exists or is needed. |
Pointer events don't cross iframe boundaries either, so the shell goes blind to the cursor the moment it enters a window. Anything shell-side that needs the real cursor position while it's over window content — today, Mio's gaze (mio.md) — arms the iframe and rebases what comes back through the iframe element's bounding rect.
Unlike the drag-hover heartbeat, this one is opt-in. It runs on every mouse move, so a shell with no consumer must not pay for it: the forwarder installs a no-op listener that returns immediately until the parent enables it.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-pointer-track |
parent → iframe | { enabled: boolean } |
Arm / disarm. Broadcast to every live iframe when the first consumer starts, re-sent to any frame that announces os-bridge-ready (so a frame re-arms after every navigation), and broadcast with enabled: false when the last consumer tears down. |
os-pointer-move |
iframe → parent | { x: number, y: number } |
The cursor in the iframe's own client coordinates. Throttled to one message per 40 ms (~25 Hz); the consumer interpolates. Coordinates only — no target element, no event object, nothing about the page content. Passive capture-phase listener; never calls preventDefault(). |
Both bridges install the forwarder behind the shared __openStationPointerForwarderInstalled sentinel, so a page carrying the inline chromeless bridge and the standalone bundle only forwards once.
Parent side: the consumer resolves the sending frame by matching MessageEvent.source against each <iframe>'s contentWindow (cached in a WeakMap), then adds that element's left / top. A message from a frame it can't resolve is dropped rather than guessed at.
A keydown inside an iframe never reaches the parent document, and every admin window is an iframe — so a shell-level chord pressed while an admin page has focus would simply do nothing. The chromeless bridge claims those three chords in capture phase, preventDefault()s + stopImmediatePropagation()s them (winning the race against Gutenberg, TinyMCE and plugin handlers bound to the same keys), and posts the intent up instead.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-palette-cycle |
iframe → parent | (none) |
Cmd/Ctrl+K. Opens the shell's command palette / Ask AI overlay rather than the in-page core/commands one, which the shell harvests anyway. |
os-window-switch |
iframe → parent | { direction: 'prev' | 'next' } |
` / Shift+`. Cycles window focus on the active desktop. |
os-window-close-all |
iframe → parent | (none) |
⌥⌘W / Ctrl+Alt+W. Runs the shell's confirm-then-closeAll() path (see javascript-reference.md). |
The backtick forwarder — and only that one — applies a text-entry gate before forwarding: a bare key with no modifier has to yield to whatever the user is typing into, including the IFRAME case that catches Gutenberg's block canvas re-dispatching cloned keydowns. The other two carry modifiers that type nothing, so they forward from anywhere.
Every chromeless iframe is a complete wp-admin page running Core's Heartbeat — 15 s in the post editor. Core only slows Heartbeat when the browser tab is hidden; a background desktop window is still a visible iframe, so that backoff never engages, and a desktop with several windows open fires several admin-ajax heartbeats a minute from windows the user isn't looking at.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-window-active |
parent → iframe | { active: boolean } |
Whether this iframe's window is the focused one. Sent on window focus/blur (src/window-activity-notifier.ts) and re-seeded on every os-bridge-ready, so a background window that navigates doesn't come back on the fast cadence. |
On active: false the chromeless bridge stretches wp.heartbeat.interval() to its 120 s maximum; on active: true it restores the saved cadence. Post locks stay safe — Core's lock window is 150 s, above the slowed interval. Two guards keep the throttle conservative: an interval below 15 s is never touched (a 5 s cadence means something urgent, like an auth-check retry, is in flight), and the restore only fires if the interval is still the 120 s the throttle set — page code that re-tuned Heartbeat while backgrounded wins.
Before tearing down an iframe-backed (non-native) window, Window.close() gives the page inside a chance to veto — the same protection a real browser tab close gets from the page's beforeunload handler, which a same-origin admin iframe never triggers on its own (there's no real navigation happening).
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-bridge-beforeunload-query |
parent → iframe | { requestId?: string } |
"Is anything holding on to this page?" Sent by close() without a requestId, and by the pre-navigation guard with one. |
os-bridge-beforeunload-response |
iframe → parent | { prevent: boolean, message?: string, requestId?: string } |
Reply, echoing the query's requestId when it carried one. prevent: true means the iframe's own beforeunload handling (window.onbeforeunload or an addEventListener('beforeunload', …) listener) set a message or called preventDefault(). |
Flow:
-
close()checkswin._iframeBridgeReady— a window whose iframe never announced readiness (still loading, or a non-openstation page) skips the query entirely and destroys immediately, same as before this feature existed. - Otherwise it posts the query, sets
win._closePending = true, and returns without destroying — a 500ms safety timer (win._iframeCloseTimeout) forces the close through if no response arrives (a hung or unresponsive iframe can't block closing forever). - Both bridge implementations (
src/chromeless-bridge.jsand the standalonesrc/iframe-bridge-standalone.ts) answer the query the same way: synthesize abeforeunloadEvent, invokewindow.onbeforeunloadwith it if set, then (if not already prevented) dispatch a realbeforeunloadevent soaddEventListener('beforeunload', …)listeners run too. Whichever mechanism setsevent.returnValueor callspreventDefault()flipsprevent: true, carrying the handler's message string through if one was set. - On the parent side,
prevent: falsedestroys the window immediately.prevent: trueshows a<os-confirm-dialog>(title = the iframe's message, or a generic fallback) — the window is only destroyed if the user confirms.
requestId is what keeps the two askers apart. The close flow reads any uncorrelated response and acts on it — it destroys the window. The pre-navigation guard below listens for its own correlated reply instead, and the window's message handler skips every response that carries a requestId so a tab click can never be mistaken for a close.
Native windows are untouched — they still use the synchronous os.native-window.before-close filter (see javascript-reference.md), not this postMessage round-trip.
Re-pointing a window's iframe is optimistic: the shell arms the loading overlay and lights the destination tab before the frame moves, so a click doesn't look like it did nothing for as long as the server takes to answer.
That optimism has exactly one failure mode, and it strands the window. When the page inside is holding unsaved changes — Add User with a generated password, an editor mid-draft, a plugin with its own beforeunload — the browser raises its native "Leave site?" prompt over the navigation. A user who answers Cancel produces no load, no os-ready, and no other event: the frame never moves, nothing corrects the guess, and the window keeps a spinner nothing will ever clear over content that never changed. There is no "the user cancelled" signal to listen for, and no timer that can infer one — the document stays alive and running whichever button was pressed, so a cancelled navigation looks identical, from outside, to a page waiting on a slow response.
So the shell asks first (src/window/unsaved-guard.ts), and on a guarded page withholds the paint rather than taking it back:
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-iframe-unloading |
iframe → parent | (none) | Posted from the document's own pagehide, which fires once, at the moment a navigation commits. The one signal that separates a prompt the user accepted from one they cancelled. |
Flow, for every submenu-tab click, Window.navigateTo() and Window.reload() on the primary frame:
- A window whose bridge has not announced readiness skips the guard entirely and behaves exactly as it did before — no query, no added latency.
- Otherwise the shell posts
os-bridge-beforeunload-querywith arequestIdand waits up to 250ms (UNSAVED_GUARD_QUERY_TIMEOUT_MS). A timeout resolves "nothing is holding on", which reinstates the pre-guard behaviour. -
prevent: false— the overwhelmingly common case — paints and navigates back to back, one task later than the click. -
prevent: true— the shell navigates but paints nothing. The browser's prompt decides. Cancel leaves the window exactly as the user left it, which is the whole fix. Leave produces a real unload, the iframe postsos-iframe-unloading, and the withheld paint runs then — late by the length of one request, the honest price of not being able to un-ring the bell. - A withheld paint that is never claimed expires after 15 s of event-loop time (
UNSAVED_GUARD_COMMIT_WINDOW_MS). Without the expiry, a navigation the user answered "Stay" to would leave a callback armed to fire on some unrelated navigation minutes later. A paint still armed when the next document announcesos-readyis dropped rather than run, for the same reason in reverse: arming the overlay for a load that has already finished spends the ready edge that would have cleared it.
The shell deliberately does not raise its own <os-confirm-dialog> here the way close() does. close() can, because destroy() removes the iframe from the DOM and removal discards a document without prompting. A navigation has no such exit: the native prompt fires whatever the shell does, so a dialog in front of it would ask the same question twice.
Windows with nothing withheld ignore os-iframe-unloading, so plugin pages that navigate for their own reasons cost nothing but the message.
When the user clicks an editor window's Preview (eye) title-bar button, the shell asks the editor page to autosave — the same thing Gutenberg's own Preview button does — while the companion window opens in parallel; a save that actually landed silently refreshes the companion, so the preview reflects on-screen content even when its first load raced the save. Deliberately not named os-bridge-*: that prefix is routed into the connection-bridge registry; this is a standalone request/response pair.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-editor-autosave-request |
parent → iframe | { requestId: string } |
"Autosave whatever you're editing, then answer." Sent by src/editor-preview/autosave.ts with a 10 s parent-side timeout. |
os-editor-autosave-response |
iframe → parent | { requestId, status: 'saved' | 'no-editor' | 'not-dirty' | 'error', previewUrl?: string } |
Reply, correlated by requestId. previewUrl is only present on the Gutenberg save-for-preview path, and only when same-origin. |
The answerer lives in the standalone bridge only (installEditorAutosaveHandler() in src/iframe-bridge-standalone.ts — installed on every admin page for OpenStation users, chromeless included, outside the bundle's double-install guard so it runs even where the inline chromeless bridge owns wp.os.iframe). Editor detection, in order:
-
Gutenberg (
wp.data.select( 'core/editor' )resolves) — prefersdispatch( 'core/editor' ).__unstableSaveForPreview(), exactly what core's Preview button calls: it autosaves when needed and resolves to the freshest preview link, returned aspreviewUrl. Fallback when that action is absent:isEditedPostAutosaveable()false →not-dirtyimmediately; otherwiseautosave()watched to completion viawp.data.subscribe(8 s best-effort backstop answerssavedanyway). -
Classic editor (
wp.autosave.server) —triggerSave()+ jQuery'safter-autosaveevent, with a 5 s backstop that answersnot-dirty, notsaved. Core'ssave()returns early whencompareString === lastCompareString: no request goes out andafter-autosavenever fires, so a silent 5 s is core declining to autosave rather than a save it forgot to announce. Answeringsavedthere made the shell refresh the preview companion ~5.4 s after the eye click for a save that never happened — late enough to read as a reaction to whatever the user clicked next. A save genuinely still in flight at 5 s is not lost: the live watch's ownafter-autosavehandler announces it when it lands. Only when jQuery is absent — unreachable in practice, sinceautosave.jsdepends on it — does the backstop assumesaved, because nothing can observe the round-trip there. -
Neither —
no-editor, immediately, so the parent never waits on a page with nothing to save.
On the parent side every non-saved outcome degrades gracefully: the preview opens at the identity's server-computed previewUrl (the last saved/autosaved revision), with a warning toast only on error.
Live-preview watch — while the preview companion is open, the shell also asks the editor page to watch its own content, because typing detection can only live iframe-side (keystrokes never cross the frame boundary):
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-editor-live-watch |
parent → iframe | { watchId: string, debounceMs: number } |
Start watching. debounceMs (clamped 500–30000) is the settle window after the last edit. A re-watch with the same watchId replaces the previous watch. |
os-editor-live-unwatch |
parent → iframe | { watchId: string } |
Stop watching (sent on pairing teardown; best-effort — the watch dies with the page anyway). |
os-editor-live-saved |
iframe → parent | { watchId: string, previewUrl?: string } |
"The editor settled and autosaved — refresh the preview." previewUrl as in the autosave response. |
Gutenberg watch mechanics: wp.data.subscribe + reference comparison of core/block-editor's block list and the edited title (every real edit replaces those references). A completing save ALSO churns those references (the save response normalizes the entity and resyncs the block list), and drafts autosave in place — Gutenberg considers them forever autosaveable — so without guards the watcher's own save reads as a fresh edit and loops. Three guards break the feedback: (1) churn arriving while isSavingPost()/isAutosavingPost() is true — and on the settle tick right after — is absorbed into the baseline without scheduling; (2) a reference change only schedules while isEditedPostDirty() (user edits set dirty synchronously; a draft's completed in-place autosave clears it); (3) the settle itself bails when isEditedPostAutosaveable() is false (published posts stay dirty relative to published content after an autosave revision — nothing new to save, nothing to refresh). On settle it also defers while a save is in flight (1 s retry), then autosaves via __unstableSaveForPreview().
Classic-editor watch mechanics: no reactive store, so typing is detected on the raw surfaces — input on the #title / #content / #excerpt fields (the three classic autosave snapshots) plus edit events on every TinyMCE editor, including editors initialized later (a visual↔text switch re-initializes). On settle the watcher forces the server autosave core would otherwise only run on its ~60 s heartbeat (wp.autosave.server.triggerSave()). Core silently drops a trigger while an autosave round-trip is on the wire, so the watcher tracks in-flight state via the before-autosave/after-autosave events and retries a settle that landed mid-save.
Both the settle and the announcement are gated on a content fingerprint — the title input plus, for the body and the excerpt, tinymce.get( field ).getContent() (falling back to the raw textarea in text mode or with no TinyMCE). Neither the bound events nor core's own bookkeeping answers "did the user change something":
-
The events don't. TinyMCE adds an undo level on blur and emits
change, and emitsSetContenton any programmatic write (init, a visual↔text switch, a plugin normalizing markup). Clicking from the editor into the preview window was enough to schedule a settle. -
Core's compare string doesn't either.
wp.autosave.getPostData()callseditor.save()as a side effect, re-serializing the TinyMCE DOM into#content. On markup core didn't write (shortcodes, WooCommerce product content, anythingwpautopround-trips differently) the re-serialized string differs from the stored one, so core's owncompareString !== lastCompareStringgate passes, the autosave goes out, andafter-autosavefires — for a post the user never touched. This is why the fingerprint must NOT be built fromgetPostData(): doing so inherits exactly that side effect and can never catch the case.
getContent() is the stable read — it serializes the same DOM every time, so an idle blur/focus cycle produces an identical string and only a real edit moves it. It is read-only by contract: a fingerprint with a side effect is the bug it exists to prevent.
So: a settle whose fingerprint matches the last announcement sends nothing, and a completed round-trip whose fingerprint matches the last announcement stays silent. The fingerprint is captured at send time (before-autosave), so an edit typed during the round-trip still counts as unannounced and gets its own save. A screen with none of the three fields fails open.
Two further rules, both needed for the first round-trip of a session:
-
An announcement additionally requires an observed edit event since the last one. The baseline is seeded when the watch starts, which is before core has ever called
getPostData()on that page — and that call'seditor.save()firesSaveContent/PostProcess, which WordPress's ownwpviewand wpautop handlers use to rewrite the editor DOM. The first autosave of a session therefore serializes differently from the seed through no user action at all. Every completed round-trip re-baselines the fingerprint whether or not it announced, so that drift is absorbed once and never looked at again.
Every other completed round-trip — forced or core's own — announces os-editor-live-saved.
The watch dies with the page (it's plain JS in the iframe), so the shell re-arms it under a fresh watchId on every readiness re-announcement while the pairing holds — the classic editor reloads on every manual save, and without the re-arm the typing-driven refresh would be permanently dead from that point. The previous watchId is unwatched first, so a readiness re-announcement without a real reload never stacks two watches.
Every chromeless iframe runs its own Heartbeat, and each heartbeat response carries core's wp-auth-check boolean (attached server-side, independent of whether the modal JS is loaded — chromeless iframes have the modal suppressed so the parent shell owns the single login prompt). When an iframe's heartbeat sees the flag flip false → true — the user re-authenticated somewhere — the bridge nudges the parent before reloading itself, so the shell's recovery (src/auth-recovery/index.ts) starts immediately instead of waiting out the parent's own heartbeat schedule.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-reauth-detected |
iframe → parent | (none) | "My heartbeat just saw the session come back." The parent forces a tick of its own (fresh nonces ride it), sweeps a reload over the other iframes, and fires os-auth-restored (see javascript-reference.md). Recovery is cooldown-gated, so the one-message-per-open-window fan-in collapses into a single run. |
-
Plugin calls
wp.os.connect( 'edit-post', { topics: [ 'gutenberg:content' ] } ). - Connection bridge mints a
connectionId(os-conn-N), stores the connection in_connections, indexes it by target window in_connectionsByTarget. - Bridge looks up the iframe via
_syntheticIframes.get( id ) ?? manager.getById( id )?.iframe. - Bridge
postMessagesos-bridge-handshaketo the iframe'scontentWindowwithtargetOrigin = INITIAL_ORIGIN. - Plugin code calls
conn.send( 'foo', payload )before the ack arrives — message goes into the connection'squeue, nopostMessageyet. - Iframe's bridge handler receives the handshake, stores the connection in its own
connectionsmap, postsos-bridge-handshake-ackback. - Parent's
routeIncomingFromIframereceives the ack, dispatches to the connection's_handleIframeMessage, which:- Sets
isOpen = true. - Fires
HOOKS.CONNECTION_OPENEDwith{ connectionId, targetWindowId, topics, connection }. Theconnectionfield is the liveWindowConnection— plug in.subscribe()directly from the hook handler without an extrawp.os.getConnection(id)round-trip. - Calls
opts.onOpen?.(). - Drains the queue with
flushQueue()— every queued message becomes a realpostMessage.
- Sets
- Iframe receives the publishes, looks up subscribers in
subs, calls each in turn.
- Iframe-side calls
wp.os.iframe.requestConnection({ topics: [ 'wpglp:content' ] }). - Iframe bridge mints a
requestId, registers a one-shot ack listener with a 5-second timeout, postsos-bridge-connection-requestto the parent. - Parent's
handleWindowMessage(or theiframeContentsynthesised render's listener) sees the bridge-prefixed message, callsrouteIncomingFromIframe( data, win.id ). -
routeIncomingFromIframerecognisesconnection-requestand callshandleConnectionRequest( windowId, requestId, topics ). - The shell runs
applyFilters( HOOKS.IFRAME_CONNECTION_REQUEST, true, { windowId, requestId, topics } ). Default value istrue(accept). Plugin code can returnfalseto reject, or{ topics: [ ... ] }to accept while narrowing. - On accept,
connect( windowId, { topics: finalTopics } )opens a parent-side connection. The parent then postsos-bridge-connection-ack { requestId, accepted: true, connectionId }back. - Iframe's ack listener resolves the original
requestConnection()promise with{ id, topics }and callsopts.onOpen?.(). - The handshake completes normally between this new connection and the iframe (the iframe's existing
os-bridge-handshakelistener picks it up and acks).
A native window registered via wp.os.registerWindow({ iframeContent }) is special: Window.iframe is null (only chromeless wp-admin pages set that), but the body contains a real <iframe> the shell created.
buildIframeContentRender:
- Creates the
<iframe>. - Calls
registerSyntheticIframe( windowId, iframe )— adds an entry to_syntheticIframesso the connection bridge's iframe lookup finds it. - Installs a
messagelistener that:- Validates
event.source === iframe.contentWindowandevent.originmatches the iframe URL's origin. - Forwards bridge-prefixed messages (
os-bridge-*) torouteIncomingFromIframe( data, windowId )so the iframe can participate inconnect()traffic. - Forwards every message (bridge or not) to
cfg.onMessage?.()so plugins that want raw access still get it.
- Validates
- On window close, the cleanup chain (passed through
onClose) callsunregisterSynth()and removes themessagelistener, so closed windows don't leak.
windowId throughout is the live instance id, resolved from the window root (id="wp-window-<windowId>") the render callback mounts into — not the id passed to registerWindow(). The two differ whenever manager.open() allocates a suffixed instance (chat → chat-2, e.g. opening the same registered window on a second virtual desktop). Anything keying off the registered id would attach the second instance's iframe, readiness signal, and channel dispatch to the first instance.
The chromeless bridge intercepts every same-origin <a href="/wp-admin/…"> click inside an iframe and lets the parent shell decide where the navigation should actually land. The decision lives in the parent because the iframe doesn't know the shell's window slug rules (which query params are identity-bearing, which URLs are remapped to a native window, which already-open window owns the destination, and so on).
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-iframe-other-admin-link |
iframe → parent | { url } |
Posted for a same-origin click that leaves this ADMIN — another site's wp-admin, or the network admin, detected by the bridge's adminScope() rule. Deliberately its own message rather than a flag on os-iframe-admin-link: the same-admin path consults remaps and dock entries that mean nothing in another admin. The shell hops to that admin's own shell, since on a network every site is its own OpenStation (see multisite.md); the flag openstation_chromeless is stripped before posting. Modifier and middle clicks never produce it — the interceptor yields those to the browser. |
os-iframe-admin-link |
iframe → parent | { url, label, newContext } |
Posted from the chromeless bridge's link interceptor for every admin-internal click that survived the modifier-key / target / download filters. label is the clicked link's visible text (falling back to its title / aria-label, truncated to 80 chars). newContext is true when the link named another browsing context, and bars the parent from driving the source window with it. The bridge preventDefaults the click first; the parent owns the navigation. |
Parent dispatch (in src/window/iframe-bridge.ts, wired by bindAdminLinkDispatch in desktop.ts):
-
Native-window remap — the URL goes through
tryNativeUrlRemap. On a hit the parent opens the native window and closes the source iframe so the brief in-flight nav never paints. -
Same-slug click —
deriveWindowId(url, adminUrl)matches the source window'sbaseIdor the slug its iframe is currently showing (getCurrentUrl()). The parent callsiframe.contentWindow.location.assign(url), which navigates the iframe in place AND adds a session-history entry. Pagination, list filtering, and per-window tab strips ride this path.Both slugs count because they diverge as soon as the iframe navigates in place: clicking the Menus tab in the Appearance window points the iframe at
nav-menus.phpwhile the window keepsbaseId: themes-php. Matching onlybaseIdwould classify the Menus screen's own tab links as cross-page and spawn a fresh window per click. The live slug only ever widens the same-page set — it never turns an in-place navigation into a new window, so a window that has navigated away still treats a link back to its landing page as in-page.The entity editor of an
admin.php?page=…screen is its own slug. A plugin routes its whole feature through one file, so a list and the editor it drills into differ only by query args — andidis too generic to count everywhere (?action=duplicate&id=3is a row action on the list, and must run in place). The rule is written against the shape instead: on anadmin.phpURL carrying apage,action=editmakesididentity-bearing andaction=newmakesactionitself identity-bearing. Every other action stays transient. WooCommerce's High-Performance Order Storage is the case that forced it — HPOS moves the order editor frompost.php?post=N&action=edit(whereposthas always been identity) toadmin.php?page=wc-orders&action=edit&id=N, so opening an order used to navigate the Orders list away, and the only route back was closing the window and reopening it from the dock. -
Cross-slug click — slug matches neither the source window's
baseIdnor its live URL. The parent callswindowManager.open({ id, baseId, url, title, icon })with title/icon copied from the matching dock entry. When no dock tile owns the destination, the title falls back to thelabelfrom the message (the clicked link's visible text), then to the derived slug as a last resort. The source iframe is left untouched, so the user keeps both contexts. When a window for the destination slug is already open,open()'s URL-aware reuse applies: if the clicked URL isn't what that window is showing (nor its home / dock landing URL), the existing window's iframe navigates to it in place — so an action URL like the post-installplugins.php?action=activate&plugin=…&_wpnonce=…link actually runs instead of being dropped by a bare focus.
Modifier-key clicks (cmd / ctrl / shift / alt, middle-click) and links carrying a download attribute short-circuit the bridge's interceptor entirely — the browser's native open-in-new-tab / save path runs unchanged.
A target is read the same way, with one exception. target="_blank" on a same-origin /wp-admin/ URL means "open this admin screen without losing the one I'm on", and inside the shell that is another OpenStation window rather than a bare browser tab — so those links are claimed like any other admin link and reach the parent as os-iframe-admin-link, carrying newContext: true. That flag bars both in-place branches above: the one thing a _blank asks for is that the page it was clicked on survives, and moving that window is worse than the browser tab it used to get. The case that forced this: the block editor's revisions sidebar renders Open classic revisions screen (revision.php?revision=N) through <ExternalLink>, which hard-codes target="_blank", so the click ejected the user into a chrome-free wp-admin tab — and under the PWA that tab is inside the app's own scope, so it relaunched the whole app.
Only when the destination is a different wp-admin file, though. Whether two URLs on the same file are the same "page" is a question about the slug rules above, and the iframe has no access to those, so a _blank to admin.php?page=x&tab=b from admin.php?page=x keeps opening a browser tab. Fewer of these become windows than could, and none of them can eat the page underneath.
Every other target still yields to the browser: _top / _parent are a deliberate "replace the whole shell" and are a page's only escape hatch, a named target (wp-preview-4) is an author reusing one specific tab across clicks which a window can't honour, and any target on a non-admin URL is a real new tab with no window to open into.
Anchors carrying core's aria-button-if-js class are left alone. That class is core's marker for "this anchor is really an in-page button, the href is only the no-JS fallback", and the script that owns the button (media-grid.js, wp-lists, tags.js, updates.js) preventDefaults it in bubble phase. Since the bridge's interceptor is capture-phase it would otherwise win the race and hand the user the fallback URL: on the Media Library grid, clicking Add Media File opened a window for media-new.php while media-grid.js expanded the inline uploader in the Media window behind it.
The class doesn't promise a handler, though. The Media list table stamps it on Trash / Restore / Delete Permanently (.submitdelete) and binds nothing, so the href really is the navigation. Those still get yielded, but the interceptor first rewrites the href to carry _wp_http_referer=<this page>, the iframe-side twin of the parent's stampSourceReferer(), which no longer sees these clicks. Without it, a Referrer-Policy of strict-origin or tighter downgrades the Referer to the bare origin, post.php matches it against neither post.php nor post-new.php, and the post-delete redirect lands on the site front page inside the window instead of back on the media list. The destination keeps rendering chromeless via the Sec-Fetch-Dest: iframe fallback in openstation_is_chromeless_request().
Links owned by core's wp-admin/js/updates.js are also left alone: the card-style install-now / update-link / update-now / delete-plugin / delete-theme / install-theme buttons, the plugins-list-table row Delete ([data-plugin] a.delete), and the network themes row Delete (.themes-php.network-admin a.delete). updates.js preventDefaults these itself and runs an in-place AJAX operation; if the bridge hijacked them, the parent-driven navigation would race the AJAX call (a wp.updates.beforeunload "Leave site?" prompt followed by the no-JS fallback screen for an already-deleted plugin).
The Dashboard's welcome panel is the same story with no marker class at all: dashboard.js binds the dismiss on the anchor and preventDefaults it, and ?welcome=0 is a dead no-JS fallback. The interceptor yields .welcome-panel-close and .welcome-panel-dismiss a inside #welcome-panel, matching core's own selector. Routing them opened a second Dashboard window titled Dismiss on top of the one being dismissed.
Jetpack's WordPress.com-built dashboards, Stats (admin.php?page=stats) and Blaze (?page=advertising), write their in-app links root-relative (/stats/day/referrers/<site>) and route them with a delegated handler on #wpcom that turns the href into a #! hash on the current screen. The interceptor yields any link inside #wpcom whose href starts with / plus the screen's page arg, which is Jetpack's own test. Claiming them resolved the href against the site root, classified it as a front-end URL, and opened the site's 404 page as an external sub-tab. Other links in those apps, such as a post permalink, are still escalated as usual.
Forms submit through a separate submit listener that only rewrites the action URL (to keep openstation_chromeless=1) and never preventDefaults. Same-origin form posts to a different page would currently navigate the iframe in place; if that becomes a UX problem it can join this protocol as a os-iframe-admin-form-submit message.
label is the link's visible text: .screen-reader-text / .hidden descendants are dropped and whitespace is collapsed before it ships. Core routinely pairs a terse visible label with a longer screen-reader one inside a single anchor — the classic post editor's revisions link is <span aria-hidden="true">Browse</span><span class="screen-reader-text">Browse revisions</span> — and reading the whole textContent titled its window "Browse Browse revisions".
A label is still only a guess. When no dock tile owns the destination, the parent opens the window with titleFromPage, and the window replaces the guess with the destination page's own screen name (document.title up to WP's ‹ separator) on every iframe load. Link text is written for someone already looking at the page it sits on, so it reads badly as a window name: "Browse" says nothing about revisions, while the page calls itself "Revisions". A locale that re-punctuates admin-header's title format falls through to the whole document title rather than being cut in the wrong place, and an os-title-change from the iframe still wins over both.
A window opened on revision.php closes itself when its iframe leaves that screen, and its destination goes through the routing above — so the editor window the user opened Revisions from is focused and pointed at the restored post, rather than the Revisions window quietly becoming a second, and the original staying stale.
Restoring a revision is a document.location assignment in wp-admin/js/revisions.js, so no click ever reaches the bridge; WP's redirect just lands wherever the frame happens to be. The URL forwarded is the one navigation timing recorded, not location.href — wp-admin/js/common.js strips WP's removable query args (message, settings-updated, …) via replaceState on DOM ready, which is before the parent's load listener runs, and message=5 is what renders the "Post restored to revision from …" notice.
This is deliberately a short list (HANDOFF_SCREENS in src/window/iframe-bridge.ts) and not a general "any window that crosses slugs hands off" rule: the submenu tab strip re-points windows across slugs on purpose (Appearance → Menus), and closing a window out from under that click would be hostile. A screen belongs here only when leaving it means the screen is finished.
The classic Users list table (users.php, rendered as a chromeless iframe) grows a "View activity footprint" row action — added server-side by openstation_user_footprint_row_action (see hooks-reference.md). Clicking it opens the target user's GitHub-style activity footprint inside the pinned WP Explorer native window, without closing the Users list.
This deliberately does NOT reuse the admin-link path above: that path closes the source iframe on a native-window remap hit (it models a navigation away). A row action is an auxiliary peek, so it gets its own message.
Carrier contract. The row-action link declares the target on the anchor itself:
| Attribute | Value |
|---|---|
data-os-footprint |
Target user id (positive integer). Required — its presence is what the bridge sniffs. |
data-os-footprint-name |
Display name, used to seed the footprint breadcrumb before the REST payload resolves. Optional. |
href |
A real user-edit.php?user_id=N / profile.php URL — the graceful fallback followed only when JS is off or on a modifier / middle click. |
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-open-user-footprint |
iframe → parent | { userId: number, userName: string } |
Posted from the chromeless bridge when a [data-os-footprint] link is clicked (checked before the admin-link classifier, so the fallback href is never followed inside the shell). The parent opens / focuses the WP Explorer window on that user's footprint route and leaves the source window open. |
Parent dispatch (src/window/iframe-bridge.ts): calls openUserFootprintWindow( { userId, userName } ) (src/open-targets/footprint-target.ts), which stashes the target in the desktop-mode/my-wordpress/footprint-target shared store, then opens the WP Explorer app (my-wordpress) via wp.os.openWindow. Cold-start safe: the app's client view reads the target on mount and subscribes for re-targets while it's already open. See javascript-reference.md.
The inline chromeless bridge runs one check before anything else: is
this page the top frame? A chromeless page is meant to live inside a
window iframe, so a top-level one is normally an accident — a stale
bookmark, a bad portal redirect — and the page has no admin bar, which
means no way to turn OpenStation off. The bridge rescues it: strip
openstation_chromeless, strip desktop_mode_portal, and
location.replace() into classic admin.
An embedder that hosts a top-level chromeless page on purpose opts out by setting a global before the page's own scripts run:
window.openStationChromelessHost = true;The bridge then leaves the URL alone. It still returns early — every
feature below the check posts to window.parent, and there isn't one —
so a hosted chromeless page gets a plain admin screen and nothing else.
The native desktop host (extensions/openstation-electron-adapter)
sets it from the preload of every window a user sets free. It has to be
a JS global rather than a query flag: a flag is lost on the first
in-page navigation, and the rescue would fire the moment the user
clicked a link inside their own window.
| Hook | Kind | Status | Payload |
|---|---|---|---|
os.connection.opened |
action | Experimental |
{ connectionId, targetWindowId, topics, connection? } — connection (the live WindowConnection) is present for iframe-target opens; native-target opens currently omit it |
os.connection.closed |
action | Experimental |
{ connectionId, reason: 'disconnect' | 'window-closed' | 'navigated' } — 'navigated' is reserved in the type union; no code path emits it yet, so today only the first two are observed |
os.connection.message |
action | Experimental |
{ connectionId, topic, direction: 'in' | 'out' } — high-volume, keep subscribers cheap |
os.iframe.connection-request |
filter | Experimental |
boolean | { topics: string[] } ← (accept, ctx) — return false to reject, an object to accept-with-narrowing |
When something's not working:
-
window.__openStationConnectionBridge— installed bydesktop.tson init. If it'sundefinedin DevTools, the shell hasn't booted yet (or you're in a frame that's not the parent shell). -
window.wp.os.iframe— the iframe-side API. If it'sundefinedinside an iframe, the bridge script wasn't loaded — for chromeless wp-admin pages it's inline; foriframeContent: { bridge: true }it's auto-injected after load; for any other same-origin iframe enqueueos-iframe-bridge. -
window.location.origincheck — every postMessage in both directions filters on this. A common cause of "messages don't arrive" is a shell mounted onhttps://example.testand an iframe loaded fromhttp://example.test(different origin); same domain ≠ same origin. -
event.source === iframe.contentWindowcheck — even same-origin, a foreign caller postingos-bridge-*messages from somewhere ELSE in the parent will be silently dropped.
Every bridge listener in this repo validates event.origin, and three of the four are strictly same-origin:
-
src/iframe-bridge-standalone.ts—parentOrigin = window.location.origin. -
src/connection/index.ts—INITIAL_ORIGIN = window.location.origin. -
src/drag-bridge.ts—this._origin = window.location.origin.
The fourth listener — src/native-windows.ts's iframeContent message handler — validates against the iframe URL's resolved origin (falling back to the shell origin for relative / invalid URLs) and forwards os-bridge-* messages into the connection registry. A native window configured with a cross-origin iframeContent.url therefore grants that foreign origin bridge access for that window: only point iframeContent.url at origins you trust.
Each postMessage's targetOrigin is set to its own captured origin, and each 'message' listener rejects events whose e.origin doesn't match. Cross-origin parents silently drop every bridge message — no warn, no fallback. This is deliberate: the bridge payloads feed into drop handlers that insert HTML and into hook subscribers that may execute code, so widening the trust boundary would create a clear XSS surface.
Concretely, the bridge will not operate in these contexts:
-
Cross-origin parent — OpenStation loaded in an
<iframe>whose parent is on a different origin (top-level admin opened outside the shell, or shell embedded in a foreign host). -
Foreign-origin Gutenberg
srcdoccanvas — by default the editor-canvas iframe inherits the parent's origin (works fine), but some plugin / theme combos overridesrcto a foreign URL. -
Sandboxed iframes (
<iframe sandbox>withoutallow-same-origin) — the iframe's origin is"null", which never matches. - PWA wrappers loading OpenStation in a foreign service-worker scope.
wp.os.iframe.isParentReachable() returns true when the parent is same-origin and addressable, false otherwise:
if ( ! wp.os.iframe.isParentReachable() ) {
// No bridge — fall back to in-iframe UI, skip the feature,
// or surface a "this view requires OpenStation" notice.
return;
}
// Bridge is live; publish away.
wp.os.iframe.publish( 'editor:content', html );The predicate accesses window.parent.location.origin inside a try/catch — cross-origin parents throw on the access. Cheap, no postMessage round-trip. Use it before wiring expensive subscriptions or showing UI that promises cross-window behavior.
A separate channel from the connection bridge. Where the connection bridge carries app-level pub/sub between a window and its iframe, the drag bridge carries an in-flight drag payload between the parent shell and ALL same-origin iframes — receivers don't need to be "connected" to receive it.
The drag bridge stores a single DragBridgePayload at any given time. Two ways the payload gets in:
-
Shell-side drag source — a DragManager
'shortcut'or'desktop-file'session whose payload carriesdata.bridgePayloadstarts (a shell-rendered tile from WP Explorer media / post / user, or an existing wallpaper placement dragged off the desktop). The shell'sDRAG_EVENTS.STARTlistener (src/desktop.ts) readspayload.data.bridgePayloadand callsdragBridge.start(payload). Cleared onDRAG_EVENTS.END. -
Iframe-side drag source — an iframe postMessages
{ type: 'os-drag-start', payload }to the parent. The bridge stores the payload and broadcastsDRAG_BRIDGE_EVENTS.STARTas aCustomEventondocumentso other shell modules can react.
Drop-receiver iframes have two ways to consume the payload:
-
Push —
src/drag/iframe-drop-targets.tssuppressespointer-eventson every iframe window for the drag's duration and registers each window body as a drop target. When the pointer is over an iframe window and the gesture is a'shortcut'or'desktop-file'drag carrying abridgePayload, the shell postMessages:Message Direction Payload os-drag-overparent → iframe { type, payload: DragBridgePayload }os-drag-moveparent → iframe { type, position: { x, y } }— the pointer in the iframe's own coordinates, at most once per animation frame while it is over the windowos-drag-leaveparent → iframe { type }os-dropparent → iframe { type, payload: DragBridgePayload, position: { x, y } }Receivers listen on
window.message, checkevent.origin === window.location.origin, and switch ondata.payload.kind. The built-in Gutenberg receiver (src/gutenberg-drop-receiver.ts) is the canonical example: it turns everyos-drag-moveinto an insertion point — the innermost block under the pointer, split at its midpoint (src/gutenberg-insertion-point.ts) — and draws Gutenberg's own blue line there through the block-editor store (showInsertionPoint), so the user sees where the block will land and can steer it;os-dropthen inserts at that( rootClientId, index ).os-drag-leaveclears the line. A pointer over the sidebar or the top bar resolves to no point: no line, and a drop there inserts where a plain insert would. -
Pull — any iframe can postMessage
{ type: 'os-drag-payload-request' }and the parent replies (directly toevent.source) with{ type: 'os-drag-payload', payload }. Useful for iframes that bind their own nativedrophandler and need the rich payload after the browser has stripped the custom MIME from DataTransfer.
While an iframe-sourced session is live, the intercept in src/drag/iframe-drop-targets.ts listens for drop on document in the capture phase — it has to, because the gesture is native HTML5 and has to be re-routed by hand into whichever iframe the cursor ended over.
That reach stops at the iframe boundary. When there is no iframe window under the cursor — the drop landed on the wallpaper, a folder window's canvas, the dock — the intercept declines: it cancels the browser default and tears the session down, but leaves propagation alone so shell-side handlers get their turn. The files canvas uses exactly that opening to file an attachment dragged out of the Media Library as a desktop shortcut (see files-on-desktop.md).
Cancelling the default is not optional even when the shell has nothing to do with the drop: a media drag carries text/uri-list, and the default action for that on a plain document is to navigate — a photo dropped on the desktop would otherwise replace the whole shell with the image.
If you register your own drop handling on a shell surface, expect the event in the bubble phase and claim it with preventDefault() + stopPropagation().
type DragBridgePayload =
| { kind: 'attachment'; id: number; url: string; title: string;
alt: string; mime: string; thumbnailUrl?: string;
sizes?: Record<string, unknown> }
| { kind: 'post'; id: number; postType: string; url: string;
title: string }
| { kind: 'user'; id: number; url: string; title: string }
| { kind: 'upload'; fileId: number; title: string; mime: string;
thumbnailUrl?: string };An upload payload is a stored desktop file — not an attachment, so it has no id and no URL a receiver could insert. The shell resolves it when the drop lands: src/drag/iframe-drop-targets.ts asks the bridge's resolver registry (registerBridgePayloadResolver( kind, resolver ) / resolveBridgePayload( payload )) before posting os-drop, and the upload resolver copies the file into the Media Library (idempotently — the same file dropped twice is one attachment) and hands back an attachment payload. The receiver therefore never sees kind: 'upload' on os-drop; it can see it on os-drag-over and on a payload pull, where it means "a media file is on its way". Resolution happens at drop, never at lift, so a drag that ends on the wallpaper or in a folder creates nothing. If the resolver fails (the user gets a toast), the iframe receives os-drag-leave instead of os-drop.
A plugin that lifts a payload kind of its own that cannot be delivered as-is registers a resolver the same way; kinds with no resolver are posted unchanged, synchronously.
-
document.body[data-os-dragging]— set by the DragManager while ANY drag is in flight. Pair with[data-os-drag-type="shortcut"]to gate drag-state CSS in the shell. -
window.wp.os.dragBridge.getPayload()— read the current cross-frame payload from anywhere in the parent shell. -
os-cross-frame-drag-start/-endCustomEvents — dispatched ondocumenteach time the bridge transitions. Plugins layer drop-zone highlights on these without polling.
If you find yourself writing window.parent.postMessage or hand-rolling a handshake, check first:
- For shell-registered iframe windows (chromeless wp-admin) → use
wp.os.connect()+wp.os.iframe.publish/subscribe. - For your own iframe pages → enqueue
os-iframe-bridgeOR setiframeContent: { bridge: true }on a native window. - For iframe-initiated requests →
wp.os.iframe.requestConnection(). - For source-validation + load-vs-listener-race →
wp.os.registerWindow({ iframeContent: { bridge: true, onMessage } })—onMessageis pre-validated against the iframe'scontentWindow, and readiness needs no callback:Window.sendpayloads queue and flush automatically once the iframe loads (HOOKS.IFRAME_READYfires for observers).
The whole "shell.js coordinator" pattern is gone if you reach for these. The plugin's parent-shell footprint goes from ~150 lines of postMessage plumbing to a ~5-line config object.
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
- The App Framework — a window in one PHP file
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Data model — where OpenStation keeps its data
- 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
- Window-scoped MIO
- Mio
- Mobile — the phone layer
- Multisite
- Native Windows & Framework Interop
- OpenStation Network
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
- Workspaces
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration — Code Blue becomes an App Framework app
- Migration — AI comment scoring leaves core
- Migration: window, wallpaper and widget bundles load on demand
- Migration — Posts, Pages, Users, User Edit, Plugins and Comments become App Framework apps
- Migration — the navigation model
- Migration — OpenStation Preferences becomes an App Framework app
- Performance settings move to Extended options
- Presence storage migration
- Migration — the Recycle Bin becomes an App Framework app
- Migration — the shell boots from its own screen
- Migration — Station Home becomes an App Framework app
- Migration: a native window's tabs move to the window chrome
- Migration — WP Explorer becomes the my-wordpress app
- Migration — WordPress package globals are no longer ambient
More
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- App layout recipes
- 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
- Repairable form edits with MIO
- Register a window companion
- Pin your app to the phone tab bar, and react to the mode
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- WP Explorer — add a column to the list view
- 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
- Ship a window as an .os.php app
- 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
- Revisions in their own window — extend or redirect "View revisions"
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Observe stored-file cleanup failures
- 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
- Place something where the user can reach it — wp.os.workArea
- Ship a workspace template